From ae8d723552b32a94bc6944088a22ddb7e1d9439f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 31 Jul 2026 12:00:39 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E9=87=8D=E6=9E=84=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E4=B8=8A=E4=B8=8B=E6=96=87=E4=B8=8E=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增编辑器级画布动作上下文与 Provider,统一承载画布动作 将 Agent 对话和任务侧栏的画布刷新迁移到上下文,移除逐层回调透传 统一生成任务完成后的项目刷新入口,避免重复加载项目快照 补齐上下文及刷新链路测试,并更新测试 Provider 装配 --- .../EditorAgentConversationPanelView.test.tsx | 20 ++++++-- .../EditorAgentConversationPanelView.tsx | 4 -- .../ToolCallView.test.tsx | 36 +++++++++---- .../EditorAgentConversation/ToolCallView.tsx | 7 +++ .../useEditorAgentConversation.test.tsx | 50 ++++++++++++++----- .../useEditorAgentConversation.ts | 8 +-- .../ImageCanvasActionsContext.test.tsx | 35 +++++++++++++ .../image-editor/ImageCanvasActionsContext.ts | 18 +++++++ .../ImageCanvasActionsProvider.tsx | 25 ++++++++++ .../ImageCanvasEditorShellView.test.tsx | 20 +++++++- .../image-editor/ImageCanvasEditorView.tsx | 16 ++---- .../image-editor/ImageCanvasStageView.tsx | 3 -- .../ImageCanvasTaskSidebarView.test.tsx | 18 ++++++- .../ImageCanvasTaskSidebarView.tsx | 5 +- 14 files changed, 209 insertions(+), 56 deletions(-) create mode 100644 src/components/image-editor/ImageCanvasActionsContext.test.tsx create mode 100644 src/components/image-editor/ImageCanvasActionsContext.ts create mode 100644 src/components/image-editor/ImageCanvasActionsProvider.tsx diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx index e64c65f6b..129779663 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx @@ -3,11 +3,12 @@ import { act, fireEvent, - render, + render as testingLibraryRender, screen, waitFor, within, } from '@testing-library/react'; +import type { ReactElement, ReactNode } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { @@ -17,6 +18,7 @@ import type { } from '@/packages/shared/src/contracts'; import type { EditorAgentConversationClient } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts'; import { EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts'; +import { ImageCanvasActionsProvider } from '@/src/components/image-editor/ImageCanvasActionsProvider.tsx'; import { useImageCanvasContextStore } from '@/src/components/image-editor/useImageCanvasContextStore.ts'; import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView.tsx'; @@ -24,6 +26,17 @@ import { EditorAgentConversationPanelView } from './EditorAgentConversationPanel const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); const probeImageFileDimensionsMock = vi.hoisted(() => vi.fn()); +const refreshCanvasMock = vi.fn(); + +function render(ui: ReactElement) { + return testingLibraryRender(ui, { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }); +} vi.mock('@/src/services/image-editor/editorProjectClient.ts', async () => { const actual = await vi.importActual< @@ -1801,14 +1814,11 @@ describe('EditorAgentConversationPanelView', () => { resolveConfirmation = resolve; }), ); - const onCanvasRefreshRequested = vi.fn(); - render( , ); @@ -1850,7 +1860,7 @@ describe('EditorAgentConversationPanelView', () => { 'internal completed tool output that must stay hidden', ), ).toBeNull(); - expect(onCanvasRefreshRequested).not.toHaveBeenCalled(); + expect(refreshCanvasMock).not.toHaveBeenCalled(); expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); }); diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx index 8d52c8c51..6df047e7b 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx @@ -42,7 +42,6 @@ type EditorAgentConversationPanelViewProps = { onToggleOpen: () => void; layers?: CanvasLayer[]; assets?: EditorAsset[]; - onCanvasRefreshRequested?: () => void; // TODO refactor: move the task list update seperate onConfirmSent?: () => void; client?: EditorAgentConversationClient; @@ -57,7 +56,6 @@ export function EditorAgentConversationPanelView({ onToggleOpen, layers = [], assets = [], - onCanvasRefreshRequested, onConfirmSent, client, }: EditorAgentConversationPanelViewProps) { @@ -93,7 +91,6 @@ export function EditorAgentConversationPanelView({ } = useEditorAgentConversation({ projectId: effectiveProjectId, client, - onCanvasRefreshRequested, onConfirmSent, }); const [draftText, setDraftText] = useState(''); @@ -310,7 +307,6 @@ export function EditorAgentConversationPanelView({ onReferenceImage={referenceContextAsset} onJobCompleted={() => { void refreshActiveConversation(); - onCanvasRefreshRequested?.(); }} /> ))} diff --git a/src/components/image-editor/EditorAgentConversation/ToolCallView.test.tsx b/src/components/image-editor/EditorAgentConversation/ToolCallView.test.tsx index 8ec8c461f..a9338a306 100644 --- a/src/components/image-editor/EditorAgentConversation/ToolCallView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/ToolCallView.test.tsx @@ -1,13 +1,16 @@ /* @vitest-environment jsdom */ import { act, render, screen, waitFor } from '@testing-library/react'; +import type { ReactElement, ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { EditorAgentToolCall } from '@/packages/shared/src/contracts'; +import { ImageCanvasActionsProvider } from '@/src/components/image-editor/ImageCanvasActionsProvider.tsx'; import ToolCallView from './ToolCallView.tsx'; const getExternalGenerationJobStatusMock = vi.hoisted(() => vi.fn()); +const refreshCanvasMock = vi.fn(); vi.mock('@/src/services/external-generation', () => ({ getExternalGenerationJobStatus: getExternalGenerationJobStatusMock, @@ -54,9 +57,20 @@ function createCompletedJobResponse(jobId: string) { }; } +function renderWithCanvasActions(ui: ReactElement) { + return render(ui, { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }); +} + describe('ToolCallView', () => { beforeEach(() => { getExternalGenerationJobStatusMock.mockReset(); + refreshCanvasMock.mockReset(); }); it.each([ @@ -65,14 +79,13 @@ describe('ToolCallView', () => { ] as const)( 'keeps the server %s state when the pending response arrives late', async (status, statusLabel, error) => { - const pendingResponse = createDeferred< - ReturnType - >(); + const pendingResponse = + createDeferred>(); getExternalGenerationJobStatusMock.mockReturnValueOnce( pendingResponse.promise, ); const onJobCompleted = vi.fn(); - const { rerender } = render( + const { rerender } = renderWithCanvasActions( { expect(screen.getByText(statusLabel)).toBeTruthy(); expect(screen.queryByText('已完成')).toBeNull(); expect(onJobCompleted).not.toHaveBeenCalled(); + expect(refreshCanvasMock).not.toHaveBeenCalled(); }, ); it('ignores job A after switching to job B and completes job B once', async () => { - const jobAResponse = createDeferred< - ReturnType - >(); - const jobBResponse = createDeferred< - ReturnType - >(); + const jobAResponse = + createDeferred>(); + const jobBResponse = + createDeferred>(); getExternalGenerationJobStatusMock.mockImplementation((jobId: string) => jobId === 'job-a' ? jobAResponse.promise : jobBResponse.promise, ); const onJobCompleted = vi.fn(); - const { rerender } = render( + const { rerender } = renderWithCanvasActions( { expect(await screen.findByText('已完成')).toBeTruthy(); expect(onJobCompleted).toHaveBeenCalledTimes(1); + expect(refreshCanvasMock).toHaveBeenCalledTimes(1); }); + }); diff --git a/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx b/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx index 65279fd36..9732d3b8b 100644 --- a/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx +++ b/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import type { EditorAgentToolCall } from '@/packages/shared/src/contracts'; import { editorAgentToolLabel } from '@/src/components/image-editor/EditorAgentConversation/toolCallPresentation.ts'; +import { useImageCanvasActions } from '@/src/components/image-editor/ImageCanvasActionsContext.ts'; import { ResolvedAssetAudio } from '@/src/components/ResolvedAssetAudio.tsx'; import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx'; import { ResolvedAssetVideo } from '@/src/components/ResolvedAssetVideo.tsx'; @@ -19,6 +20,7 @@ function ToolCallView({ onJobCompleted?: () => void; onRightClickMenu?: RightClickMenuHandler; }) { + const { refreshCanvas } = useImageCanvasActions(); const videos = toolCall.videos ?? []; const audios = toolCall.audios ?? []; const jobId = toolCall.externalJobId?.trim() || null; @@ -37,9 +39,13 @@ function ToolCallView({ useState(initialDisplayError); const terminalNotifiedRef = useRef(false); const onJobCompletedRef = useRef(onJobCompleted); + const refreshCanvasRef = useRef(refreshCanvas); useEffect(() => { onJobCompletedRef.current = onJobCompleted; }, [onJobCompleted]); + useEffect(() => { + refreshCanvasRef.current = refreshCanvas; + }, [refreshCanvas]); useEffect(() => { setDisplayStatus(initialDisplayStatus); setDisplayError(initialDisplayError); @@ -59,6 +65,7 @@ function ToolCallView({ setDisplayError(response.job.error ?? null); if (!terminalNotifiedRef.current) { terminalNotifiedRef.current = true; + refreshCanvasRef.current(); onJobCompletedRef.current?.(); } return; diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx index 03ff1b3c8..3f1aa8c3e 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx @@ -1,6 +1,13 @@ /* @vitest-environment jsdom */ -import { act, renderHook, waitFor } from '@testing-library/react'; +import { + act, + renderHook as testingLibraryRenderHook, + type RenderHookOptions, + type RenderHookResult, + waitFor, +} from '@testing-library/react'; +import type { ReactNode } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -9,12 +16,37 @@ import { type EditorAgentMessage, type EditorAgentMessageResponse, } from '../../../../packages/shared/src/contracts/editorAgent.ts'; +import { ImageCanvasActionsProvider } from '../ImageCanvasActionsProvider.tsx'; import { EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS, type EditorAgentConversationClient, useEditorAgentConversation, } from './useEditorAgentConversation.ts'; +const refreshCanvasMock = vi.fn(); + +function ImageCanvasActionsTestWrapper({ + children, +}: { + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +function renderHook( + callback: (initialProps: Props) => Result, + options?: RenderHookOptions, +): RenderHookResult { + return testingLibraryRenderHook(callback, { + ...options, + wrapper: ImageCanvasActionsTestWrapper, + }); +} + function createEditImageDisplayArgs(prompt: string) { return { stringArgs: [{ name: 'prompt', label: '修改要求', value: prompt }], @@ -124,12 +156,10 @@ describe('useEditorAgentConversation', () => { it('loads conversations and applies delta messages', async () => { const client = createClient(); - const onCanvasRefreshRequested = vi.fn(); const { result } = renderHook(() => useEditorAgentConversation({ projectId: 'project-1', client, - onCanvasRefreshRequested, }), ); @@ -156,7 +186,7 @@ describe('useEditorAgentConversation', () => { expect(result.current.activeConversation?.title).toBe( '把这个角色改成像素风', ); - expect(onCanvasRefreshRequested).toHaveBeenCalledTimes(1); + expect(refreshCanvasMock).toHaveBeenCalledTimes(1); expect(result.current.messages.map((message) => message.text)).toEqual([ '把这个角色改成像素风', '我来处理', @@ -595,7 +625,6 @@ describe('useEditorAgentConversation', () => { it('does not apply a completed message response after switching conversations', async () => { const client = createClient(); - const onCanvasRefreshRequested = vi.fn(); let resolveSend!: (response: EditorAgentMessageResponse) => void; vi.mocked(client.sendMessage).mockImplementation( () => @@ -607,7 +636,6 @@ describe('useEditorAgentConversation', () => { useEditorAgentConversation({ projectId: 'project-1', client, - onCanvasRefreshRequested, }), ); @@ -682,7 +710,7 @@ describe('useEditorAgentConversation', () => { expect(result.current.activeConversationId).toBe('conversation-2'); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0]?.text).toBe('第二个会话原有消息'); - expect(onCanvasRefreshRequested).not.toHaveBeenCalled(); + expect(refreshCanvasMock).not.toHaveBeenCalled(); expect( result.current.conversations.find( (conversation) => conversation.conversationId === 'conversation-1', @@ -931,13 +959,11 @@ describe('useEditorAgentConversation', () => { createdAt: '2026-07-03T00:00:00.000Z', }; vi.mocked(client.confirmToolCall).mockResolvedValue(undefined); - const onCanvasRefreshRequested = vi.fn(); const onConfirmSent = vi.fn(); const { result } = renderHook(() => useEditorAgentConversation({ projectId: 'project-1', client, - onCanvasRefreshRequested, onConfirmSent, }), ); @@ -981,7 +1007,7 @@ describe('useEditorAgentConversation', () => { expect(result.current.messages[1]?.toolCall?.externalJobId).toBe( 'task-edit-1', ); - expect(onCanvasRefreshRequested).not.toHaveBeenCalled(); + expect(refreshCanvasMock).not.toHaveBeenCalled(); expect(onConfirmSent).toHaveBeenCalledTimes(1); }); @@ -1109,12 +1135,10 @@ describe('useEditorAgentConversation', () => { createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:00:00.000Z', }); - const onCanvasRefreshRequested = vi.fn(); const { result } = renderHook(() => useEditorAgentConversation({ projectId: 'project-1', client, - onCanvasRefreshRequested, }), ); @@ -1135,7 +1159,7 @@ describe('useEditorAgentConversation', () => { ); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0]?.toolCall?.status).toBe('cancelled'); - expect(onCanvasRefreshRequested).not.toHaveBeenCalled(); + expect(refreshCanvasMock).not.toHaveBeenCalled(); }); it('rethrows fetch errors and rolls back the optimistic message', async () => { diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts index e8f610dd5..42787b74d 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts @@ -9,6 +9,7 @@ import type { EditorAgentMessageRequest, EditorAgentMessageResponse, } from '@/packages/shared/src/contracts'; +import { useImageCanvasActions } from '@/src/components/image-editor/ImageCanvasActionsContext.ts'; import { cancelEditorAgentToolCall, @@ -47,7 +48,6 @@ export type EditorAgentConversationClient = { type UseEditorAgentConversationOptions = { projectId?: string | null; client?: EditorAgentConversationClient; - onCanvasRefreshRequested?: () => void; onConfirmSent?: () => void; }; @@ -135,9 +135,9 @@ function upsertConversationSummary( export function useEditorAgentConversation({ projectId, client = defaultEditorAgentConversationClient, - onCanvasRefreshRequested, onConfirmSent, }: UseEditorAgentConversationOptions) { + const { refreshCanvas } = useImageCanvasActions(); const normalizedProjectId = projectId?.trim() ?? ''; const [conversations, setConversations] = useState< EditorAgentConversationSummary[] @@ -424,10 +424,10 @@ export function useEditorAgentConversation({ ); }) ) { - onCanvasRefreshRequested?.(); + refreshCanvas(); } }, - [onCanvasRefreshRequested], + [refreshCanvas], ); const applyDeltaMessages = useCallback( diff --git a/src/components/image-editor/ImageCanvasActionsContext.test.tsx b/src/components/image-editor/ImageCanvasActionsContext.test.tsx new file mode 100644 index 000000000..673acf789 --- /dev/null +++ b/src/components/image-editor/ImageCanvasActionsContext.test.tsx @@ -0,0 +1,35 @@ +/* @vitest-environment jsdom */ + +import { act, renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { + useImageCanvasActions, +} from './ImageCanvasActionsContext'; +import { ImageCanvasActionsProvider } from './ImageCanvasActionsProvider'; + +describe('ImageCanvasActionsContext', () => { + it('exposes the editor-scoped canvas actions', () => { + const refreshCanvas = vi.fn(); + const { result } = renderHook(useImageCanvasActions, { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }); + + act(() => { + result.current.refreshCanvas(); + }); + + expect(refreshCanvas).toHaveBeenCalledTimes(1); + }); + + it('fails clearly when consumed outside the editor provider', () => { + expect(() => renderHook(useImageCanvasActions)).toThrow( + 'useImageCanvasActions must be used within ImageCanvasActionsProvider', + ); + }); +}); diff --git a/src/components/image-editor/ImageCanvasActionsContext.ts b/src/components/image-editor/ImageCanvasActionsContext.ts new file mode 100644 index 000000000..99d681712 --- /dev/null +++ b/src/components/image-editor/ImageCanvasActionsContext.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from 'react'; + +export type ImageCanvasActions = { + refreshCanvas: () => void; +}; + +export const ImageCanvasActionsContext = + createContext(null); + +export function useImageCanvasActions() { + const actions = useContext(ImageCanvasActionsContext); + if (!actions) { + throw new Error( + 'useImageCanvasActions must be used within ImageCanvasActionsProvider', + ); + } + return actions; +} diff --git a/src/components/image-editor/ImageCanvasActionsProvider.tsx b/src/components/image-editor/ImageCanvasActionsProvider.tsx new file mode 100644 index 000000000..222d0a69e --- /dev/null +++ b/src/components/image-editor/ImageCanvasActionsProvider.tsx @@ -0,0 +1,25 @@ +import { type ReactNode, useMemo } from 'react'; + +import { + type ImageCanvasActions, + ImageCanvasActionsContext, +} from './ImageCanvasActionsContext'; + +export function ImageCanvasActionsProvider({ + children, + refreshCanvas, +}: { + children: ReactNode; + refreshCanvas: ImageCanvasActions['refreshCanvas']; +}) { + const actions = useMemo( + () => ({ refreshCanvas }), + [refreshCanvas], + ); + + return ( + + {children} + + ); +} diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 23f387937..bdf6f63fd 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -1,9 +1,15 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen, within } from '@testing-library/react'; -import { createRef } from 'react'; +import { + fireEvent, + render as testingLibraryRender, + screen, + within, +} from '@testing-library/react'; +import { createRef, type ReactElement, type ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { ImageCanvasActionsProvider } from './ImageCanvasActionsProvider'; import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; import type { ImageCanvasMetadataModalViewProps } from './ImageCanvasMetadataModalView'; @@ -11,6 +17,16 @@ import type { ImageCanvasSidebarViewProps } from './ImageCanvasSidebarView'; import type { ImageCanvasStageViewProps } from './ImageCanvasStageView'; import type { ImageCanvasTopbarViewProps } from './ImageCanvasTopbarView'; +function render(ui: ReactElement) { + return testingLibraryRender(ui, { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }); +} + function createLayer(overrides: Partial = {}): CanvasLayer { return { id: 'layer-1', diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index ce516ccd9..a8791887e 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -32,6 +32,7 @@ import { PlatformRechargePaymentResultDialog, } from '../platform-entry/PlatformRechargePaymentStatusDialogs'; import { usePlatformProfileCenterController } from '../platform-entry/usePlatformProfileCenterController'; +import { ImageCanvasActionsProvider } from './ImageCanvasActionsProvider'; import { canvasAssetKindOrNull, DEFAULT_CANVAS_BACKGROUND_COLOR, @@ -1195,7 +1196,7 @@ export function ImageCanvasEditorView({ }, [applyProjectSnapshot, captureCanvasHistory, refreshAssetLibrary], ); - const handleEditorAgentCanvasRefreshRequested = useCallback(() => { + const refreshCanvas = useCallback(() => { if (!projectId) { return; } @@ -1469,10 +1470,6 @@ export function ImageCanvasEditorView({ const handleEditorAgentConfirmSent = useCallback(() => { generationSurface.refreshTaskList(); }, [generationSurface]); - const handleEditorAgentJobCompleted = useCallback(() => { - generationSurface.refreshTaskList(); - handleEditorAgentCanvasRefreshRequested(); - }, [generationSurface, handleEditorAgentCanvasRefreshRequested]); const showGenerationWarning = generationSurface.showGenerationWarning; const handleExternalGenerationTasksCompleted = useCallback( (tasks: ExternalGenerationTaskRecord[]) => { @@ -1486,12 +1483,8 @@ export function ImageCanvasEditorView({ showGenerationWarning(warning); } refreshEditorWalletState(); - void loadEditorProject(projectId) - .then(applyGeneratedProjectSnapshot) - .catch(() => undefined); }, [ - applyGeneratedProjectSnapshot, projectId, refreshEditorWalletState, showGenerationWarning, @@ -2400,7 +2393,6 @@ export function ImageCanvasEditorView({ onActivateGenerationDialog: activateCanvasGenerationDialog, onFocusExternalTask: focusExternalGenerationTask, onExternalTasksCompleted: handleExternalGenerationTasksCompleted, - onEditorAgentCanvasRefreshRequested: handleEditorAgentJobCompleted, onEditorAgentConfirmSent: handleEditorAgentConfirmSent, onToggleTaskSidebar: toggleTaskSidebar, onToggleAgentConversation: toggleAgentConversation, @@ -2465,7 +2457,7 @@ export function ImageCanvasEditorView({ }; return ( - <> + ) : null} - + ); } diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index fee6cf878..1a9f6ae1b 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -134,7 +134,6 @@ export type ImageCanvasStageViewProps = { onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void; onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void; onExternalTasksCompleted?: (tasks: ExternalGenerationTaskRecord[]) => void; - onEditorAgentCanvasRefreshRequested?: () => void; onEditorAgentConfirmSent?: () => void; onToggleTaskSidebar: () => void; onToggleAgentConversation: () => void; @@ -271,7 +270,6 @@ export function ImageCanvasStageView({ onActivateGenerationDialog, onFocusExternalTask, onExternalTasksCompleted, - onEditorAgentCanvasRefreshRequested, onEditorAgentConfirmSent, onToggleTaskSidebar, onToggleAgentConversation, @@ -512,7 +510,6 @@ export function ImageCanvasStageView({ onToggleOpen={onToggleAgentConversation} layers={layers} assets={editorAgentAssets} - onCanvasRefreshRequested={onEditorAgentCanvasRefreshRequested} onConfirmSent={onEditorAgentConfirmSent} /> ) : null} diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx index d13243333..e5479ba81 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx @@ -3,17 +3,31 @@ import { act, fireEvent, - render, + render as testingLibraryRender, screen, waitFor, } from '@testing-library/react'; +import type { ReactElement, ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration'; import { listExternalGenerationTasks } from '../../services/external-generation'; +import { ImageCanvasActionsProvider } from './ImageCanvasActionsProvider.tsx'; import { ImageCanvasTaskSidebarView } from './ImageCanvasTaskSidebarView'; import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts'; +const refreshCanvasMock = vi.fn(); + +function render(ui: ReactElement) { + return testingLibraryRender(ui, { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }); +} + vi.mock('../../services/external-generation', () => ({ listExternalGenerationTasks: vi.fn().mockResolvedValue({ overview: { @@ -30,6 +44,7 @@ const listExternalGenerationTasksMock = vi.mocked(listExternalGenerationTasks); beforeEach(() => { useImageCanvasContextStore.getState().setProjectId('project-1'); + refreshCanvasMock.mockReset(); listExternalGenerationTasksMock.mockClear(); listExternalGenerationTasksMock.mockResolvedValue({ overview: { @@ -448,6 +463,7 @@ describe('ImageCanvasTaskSidebarView', () => { }), ]); expect(onExternalTasksCompleted).toHaveBeenCalledTimes(1); + expect(refreshCanvasMock).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); } diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx index 7dcbf6c7a..d987ce431 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx @@ -17,6 +17,7 @@ import { import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration'; import { listExternalGenerationTasks } from '../../services/external-generation'; +import { useImageCanvasActions } from './ImageCanvasActionsContext.ts'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import type { CanvasTaskStatus } from './ImageCanvasEditorTypes'; import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts'; @@ -280,6 +281,7 @@ export function ImageCanvasTaskSidebarView({ onFocusExternalTask, onExternalTasksCompleted, }: ImageCanvasTaskSidebarViewProps) { + const { refreshCanvas } = useImageCanvasActions(); const projectId = useImageCanvasContextStore((state) => state.projectId); const normalizedProjectId = projectId?.trim() ?? ''; const [activeTab, setActiveTab] = useState('active'); @@ -334,9 +336,10 @@ export function ImageCanvasTaskSidebarView({ for (const task of newlyCompletedTasks) { completedRefreshNotifiedTaskIdsRef.current.add(task.jobId); } + refreshCanvas(); onExternalTasksCompleted?.(newlyCompletedTasks); }, - [onExternalTasksCompleted], + [onExternalTasksCompleted, refreshCanvas], ); useEffect(() => { -- 2.52.0 From 0173dcf6ef77535187810dadef6b08f504601672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 31 Jul 2026 12:04:58 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=AA=92=E4=BD=93=E7=94=BB=E5=B8=83=E5=AE=9A=E4=BD=8D=E4=B8=8E?= =?UTF-8?q?=E5=B9=B3=E6=BB=91=E8=81=9A=E7=84=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为生成图片、视频和音频的右键菜单新增画布定位动作,普通点击与键盘保持被动 根据资源标识查找对应画布图层并执行平滑视口聚焦,不改变当前选择与面板状态 同步视口状态引用、动画取消与减少动态效果逻辑,保证连续操作结果一致 补齐媒体定位和视口控制测试,并更新相关技术文档与决策记录 --- .../shared-memory/decision-log.md | 10 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...构】图片画布编辑器前端拆分计划-2026-06-17.md | 8 +- .../【编辑器】画布Agent对话面板-2026-07-03.md | 10 +- .../EditorAgentConversationPanelView.test.tsx | 7 +- .../MessageBubble.test.tsx | 94 ++++++++- .../EditorAgentConversation/MessageBubble.tsx | 7 +- .../MessageBubbleRightClickMenu.tsx | 62 ++++-- .../ToolCallView.test.tsx | 99 ++++++++- .../EditorAgentConversation/ToolCallView.tsx | 190 ++++++++++-------- .../EditorAgentConversation/common.ts | 2 + .../useEditorAgentConversation.test.tsx | 6 +- .../useRightClickMenu.ts | 28 ++- .../ImageCanvasActionsContext.test.tsx | 8 +- .../image-editor/ImageCanvasActionsContext.ts | 1 + .../ImageCanvasActionsProvider.tsx | 6 +- .../ImageCanvasEditorShellView.test.tsx | 5 +- .../ImageCanvasEditorView.test.tsx | 48 ++++- .../image-editor/ImageCanvasEditorView.tsx | 30 ++- .../ImageCanvasTaskSidebarView.test.tsx | 7 +- .../useImageCanvasViewportControls.test.tsx | 86 ++++++++ .../useImageCanvasViewportControls.ts | 113 ++++++++++- 22 files changed, 675 insertions(+), 154 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bab05cac6..63bf0773e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,16 @@ --- +## 2026-07-30 画布 Agent 结果通过实例级 Action Context 刷新并从右键菜单定位资源 + +- 背景:画布 Agent 图片、视频和音频结果需要提供画布定位和结果完成后的工程刷新;若继续从舞台向面板、消息和工具结果逐层传 callback,会扩大现有 prop drilling,而把 callback 或瞬时命令放入全局 Zustand 又会引入多实例和卸载残留问题。直接把点击和定位语义附到生成媒体上还会让视频 / 音频的播放、暂停、拖动与音量操作误触发画布聚焦,并给原生媒体控件附加错误的定位标签。 +- 决策:`ImageCanvasEditorView` 提供实例级 `ImageCanvasActionsContext`,暴露 `focusResource(resourceId)` 与 `refreshCanvas()`;Agent 工具结果刷新、生成结果右键菜单和任务侧栏直接消费对应动作,不新增中间 props,也不扩展现有只保存 `projectId` 的 Zustand store。`refreshCanvas()` 统一重新读取当前工程快照并刷新素材库,任务列表入队后的立即失效继续保持独立。带有效 `resourceId` 的图片、视频和音频生成结果只在素材右键菜单显示“在画布中定位”,普通媒体卡片不声明按钮语义或 `tabIndex`,点击、Enter 和 Space 均不触发定位;视频和音频原生播放器只使用描述媒体自身的标签,不承载定位标签或点击处理。`focusResource(resourceId)` 命中当前图层后只按完整画布 viewport 播放固定 `420ms` ease-out fit 动画,不改变图层选择、工具、侧栏或 Agent 面板;普通 viewport 写入和用户交互可取消动画,reduced-motion 直接完成,缺失 ID 或图层时无动作。 +- 影响范围:图片画布 Action Context、viewport controls、Agent 结果媒体交互、前端测试和编辑器专题文档;不修改共享 DTO、后端 API 或 SpacetimeDB。 +- 验证方式:覆盖 Context 作用域、三类媒体普通点击无动作与右键菜单分发、原生媒体控件无定位标签、动画中间帧与终态、手动取消、reduced-motion,以及编辑器集成中选择态和面板保持不变;运行前端定向测试、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md`。 + +--- + ## 2026-07-29 图集切片必须受前置容量和有界 CPU 保护 - 背景:图标与 UI 图集的 alpha 连通域识别会在 async handler 上同步执行;原始连通域合并采用全量两两比较,`64` 个输出限制又晚于排序、裁剪和 PNG 编码。碎块或噪点图会放大 CPU 与内存成本,手动拆分、图标自动拆分和 UI 提取都受影响。另一方面,图标与 UI 的 Alpha 尺寸恢复、provider 原图回读或透明图解码失败此前只记日志,仍会把不可信透明图持久化并拆分。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 2c169cfe6..901201cdf 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -153,7 +153,7 @@ - Agent 面板能读取当前工程会话列表;无历史会话时发送第一条消息会先创建“新对话”。支持新建会话、切换会话和删除当前会话;删除必须通过独立确认弹窗完成,不能在面板下方追加确认内容。 - Agent 输入支持文本消息、附件消息和纯附件消息;附件选择弹窗可在“画布 / 素材库”之间切换,只展示图片类资源,最多选择 9 张。 - 发送消息后,面板先展示本地用户消息和请求等待态,再应用普通 JSON 响应中的 `deltaMessages`;客户端取消等待只终止本次 transport 等待,不把已经确认入队的外部生成任务改成停止态。 -- Agent 工具任务完成并懒回填后,消息内缩略图只作纯预览,不显示名称也不点击聚焦图层;前端同时重新读取工程快照和素材库。对话入口触发生成时不创建“即将生成”画布占位,生成完成后由后端 `canvasCompletion` 落新图层。规划或工具失败时消息内必须保留可回读的失败状态和错误气泡,不能只弹一次性 toast 或返回瞬时 `errorMessage`。 +- Agent 工具任务完成并懒回填后,消息内缩略图不显示名称;前端通过编辑器作用域 Action Context 的 `refreshCanvas()` 直接重新读取工程快照和素材库,不从 Editor 经 Stage、Panel 和 MessageBubble 透传刷新 callback。图片、视频和音频结果携带有效 `resourceId` 时,在素材右键菜单显示“在画布中定位”,并通过同一 Context 的 `focusResource(resourceId)` 请求画布在 `420ms` 内平滑 fit 到对应图层;普通媒体卡片不声明按钮语义或 `tabIndex`,点击、Enter 和 Space 均不得触发定位,视频和音频的原生播放器交互保持独立。定位只改变 viewport,不选择图层、不切换工具或侧栏、不收起 Agent 面板,也不避让面板覆盖区。缺少 `resourceId` 时不显示定位菜单项,目标图层已删除时保持无动作。对话入口触发生成时不创建“即将生成”画布占位,生成完成后由后端 `canvasCompletion` 落新图层。规划或工具失败时消息内必须保留可回读的失败状态和错误气泡,不能只弹一次性 toast 或返回瞬时 `errorMessage`。 - 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。 - 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`;父流程解析并校验私有 OSS object key 后只调用一次唯一内部 `bgfilter-worker` 的 complex 链路,子 worker 负责签发 600 秒 URL、`N / Q` 限流和最多两次顺序 provider attempt,complex 失败不接入 fallback,成功二进制返回后仍由父流程完成最终持久化。有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 68c306b07..69b40183f 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -51,6 +51,10 @@ 第三阶段以后,主视图仍是画布编排入口。继续拆分前应优先选择能形成稳定边界的深模块,避免把上传链路、DataTransfer、画布坐标和历史快照拆成互相回调的小碎片。 +- `ImageCanvasActionsContext.ts` / `ImageCanvasActionsProvider.tsx` + - 承载编辑器实例内深层组件向画布编排入口发起的窄动作协议;当前暴露按 `resourceId` 请求视口聚焦的 `focusResource(resourceId)`,以及重新读取当前工程快照并刷新素材库的 `refreshCanvas()`,不保存图层、视口或正式业务状态。 + - Provider 由 `ImageCanvasEditorView` 持有,Agent 工具结果刷新、生成结果素材右键菜单和任务侧栏直接消费 Context;不得为这些画布动作新增 `Stage → Panel → MessageBubble` props 链,也不得把 callback 或一次性命令注册进全局 Zustand store。生成结果普通点击以及视频 / 音频播放器操作不消费 `focusResource`。任务列表自身的入队后立即失效不属于画布刷新,继续保持独立。 + ## 第四阶段模块 - `ImageCanvasGenerationComposerView.tsx` @@ -151,9 +155,9 @@ ## 第十七阶段模块 - `useImageCanvasViewportControls.ts` - - 承载画布视口控制:`viewport`、`canvasSize`、小地图投影、适合视图、中心缩放、普通滚轮按原始 `deltaX / deltaY` 二维平移、Shift 且 `deltaX = 0` 时的横向位移适配、Ctrl / Cmd 滚轮缩放、屏幕点到画布 / 世界坐标换算和小地图点击 / 拖拽移动视图。 + - 承载画布视口控制:`viewport`、`canvasSize`、小地图投影、适合视图、中心缩放、普通滚轮按原始 `deltaX / deltaY` 二维平移、Shift 且 `deltaX = 0` 时的横向位移适配、Ctrl / Cmd 滚轮缩放、屏幕点到画布 / 世界坐标换算、小地图点击 / 拖拽移动视图,以及可由普通视口写入或用户交互取消的程序化平滑聚焦动画。 - 主视图继续负责图层拖拽、生成占位框拖拽、框选、多选、历史触发时机、上传 drop 分流和小地图 pointer down 事件;该 hook 只作为视口控制协调器,不接管画布完整 pointer 状态机。 - - 该 hook 用独立单测覆盖尺寸同步、适合视图、中心缩放、坐标换算、滚轮语义和小地图移动,为后续抽 `useImageCanvasStageInteractions` 预留更清晰的视口接口。 + - 该 hook 用独立单测覆盖尺寸同步、适合视图、中心缩放、坐标换算、滚轮语义、小地图移动、动画插值、重定向、手动取消和 reduced-motion,为后续抽 `useImageCanvasStageInteractions` 预留更清晰的视口接口。 ## 第十八阶段模块 diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index c96c427c2..23d720a71 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -1,6 +1,6 @@ # 画布Agent对话面板 -日期:`2026-07-23` +日期:`2026-07-29` ## 定位与边界 @@ -50,7 +50,7 @@ ## 生成结果落画板(对现有占位规则的例外) - 对话入口触发的生成**不创建"即将生成"画布占位**(区别于其余生成面板);生成中状态由工具消息和外部任务状态承载。 -- 生成完成后:结果图按统一 placement 避让模型(视口中心就近、避开现有图层、32px 间距)落画板为新图层,同时登记到默认项目素材库;前端轮询到任务终态并重新读取会话后,以回填的轻量媒体引用显示纯缩略图并刷新工程快照与素材库,缩略图本身不显示名称也不承担图层跳转。 +- 生成完成后:结果图按统一 placement 避让模型(视口中心就近、避开现有图层、32px 间距)落画板为新图层,同时登记到默认项目素材库;前端轮询到任务终态或收到已经包含结果媒体的会话增量时,直接通过编辑器作用域 `ImageCanvasActionsContext.refreshCanvas()` 重新读取工程快照并刷新素材库,不经过 Stage、Agent 面板或消息组件逐层透传 callback。重新读取会话后,以回填的轻量媒体引用显示缩略图。图片、视频或音频结果携带有效 `resourceId` 时,右键素材后通过“在画布中定位”调用同一 Context 的 `focusResource(resourceId)`,请求画布视口在 `420ms` 内平滑 fit 到对应图层;普通媒体卡片不承载按钮语义、不进入键盘 Tab 顺序,普通点击以及 Enter / Space 均不触发画布聚焦,视频和音频的播放、暂停、拖动、音量等原生控制只操作播放器。定位不选中图层、不切换工具或侧栏、不收起 Agent 面板,也不为 Agent 面板预留可见区域。 - 消息内生成结果缩略图必须携带并优先使用 `objectKey` / `assetObjectId`,前端通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签后渲染,不能把裸 `/generated-*` 私有路径直接交给 ``。 - 既有编辑器 worker 通过 `canvasCompletion` 写回工程快照;刷新后由 external generation task 状态和会话懒回填恢复结果。 - 该例外已同步登记在《生成类面板Lovart统一改造方案-2026-06-17》「画布占位落点」节。 @@ -126,11 +126,11 @@ 1. 助手文本随普通 JSON 消息响应一次性返回; 2. 消息请求等待态,以及工具任务的待确认、生成中、完成 / 失败状态; 3. 工具/模型标注行(生成时显示模型名 + 图标); -4. 消息内生成结果缩略图(纯预览,不显示名称,不点击聚焦图层); +4. 消息内生成结果缩略图不显示名称;携带有效 `resourceId` 时可从素材右键菜单选择“在画布中定位”,以平滑视口动画定位对应画布图层;普通点击只保留媒体自身交互; 5. 生成中的进行中动画; 6. 错误气泡(失败/余额不足,带原因); 7. 普通消息请求等待期间禁用发送按钮,不提供客户端停止操作;前端持续等待后端响应,超过 120 秒但 POST 仍 pending 时在思考气泡中显示“仍在处理中,请耐心等待”,最终成功或失败后自动移除,避免后端已持久化消息但前端中断请求后产生会话状态错位。 -8. 桌面端右键消息正文可复制该条可见文本;右键消息附件或生成结果可下载素材,图片额外支持复制图片本体和“引用”到当前输入区。引用复用附件去重、9 张上限和发送链路; +8. 桌面端右键消息正文可复制该条可见文本;右键消息附件或生成结果可下载素材,图片额外支持复制图片本体和“引用”到当前输入区,带有效 `resourceId` 的图片、视频和音频生成结果额外支持“在画布中定位”。引用复用附件去重、9 张上限和发送链路; 9. 消息右键菜单遵循 Canva 式单实例交互:任一菜单已打开时,下一次右键必须先关闭旧菜单;新落点是消息正文或素材时再在新位置打开对应菜单,新落点没有右键动作时仅收起旧菜单,不允许多个消息菜单并存。复制、引用或下载成功后自动关闭菜单;失败时保留菜单和失败状态,避免错误无提示消失。 不做(明确排除,防止后人补齐): @@ -172,6 +172,6 @@ - 打开画布 Agent 后任务侧栏应关闭,再次打开任务侧栏时 Agent 面板应关闭;素材 / 图层面板与 Agent 可同时展开,互不改写开关状态。 - 发送消息时先本地追加用户消息,再应用 JSON 响应中的 `deltaMessages`;请求等待期间发送按钮保持禁用,前端不主动中断当前回合。 -- Agent 消息内生成结果缩略图只用于预览,不显示名称,也不点击跳转图层;轮询到任务终态并完成会话懒回填后统一刷新工程快照和素材库。 +- Agent 消息内生成结果缩略图不显示名称;轮询到任务终态并完成会话懒回填后,通过实例级 `refreshCanvas()` 统一刷新工程快照和素材库,禁止重新引入中间刷新 props。只允许从有效结果的素材右键菜单触发画布定位,普通点击以及视频 / 音频原生播放器操作不得触发;定位时必须保持 Agent 面板、当前图层选择、工具和侧栏不变,只平滑调整 viewport 到对应图层;旧消息缺少 `resourceId` 或目标图层已删除时不显示定位菜单项。 - 对话内容可被用户选中复制;用户从输入框或对话内容点击回画布图层 / 生成器时,焦点应回到画布对象,Backspace / Delete 等画布快捷键继续生效。 - 对话正文右键菜单只复制当前气泡展示的完整文本,隐藏的内部 system 文本不得进入菜单;素材右键菜单优先于正文菜单,私有素材继续通过既有读取链路换签或代理下载,不复制会过期的临时链接。 diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx index 129779663..444268330 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx @@ -26,12 +26,16 @@ import { EditorAgentConversationPanelView } from './EditorAgentConversationPanel const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); const probeImageFileDimensionsMock = vi.hoisted(() => vi.fn()); +const focusResourceMock = vi.fn(); const refreshCanvasMock = vi.fn(); function render(ui: ReactElement) { return testingLibraryRender(ui, { wrapper: ({ children }: { children: ReactNode }) => ( - + {children} ), @@ -197,6 +201,7 @@ function createPendingToolCallMessage(): EditorAgentMessage { describe('EditorAgentConversationPanelView', () => { beforeEach(() => { + focusResourceMock.mockReset(); useImageCanvasContextStore.getState().setProjectId('project-1'); uploadEditorMediaAssetFileMock.mockReset(); uploadEditorMediaAssetFileMock.mockResolvedValue({ diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx index 1ba2db6c3..cc5bfbe51 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx @@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { EditorAgentMessage } from '@/packages/shared/src/contracts'; +import { ImageCanvasActionsProvider } from '@/src/components/image-editor/ImageCanvasActionsProvider.tsx'; import { readAssetBytes } from '@/src/services/assetReadUrlService.ts'; import { copyTextToClipboard } from '@/src/services/clipboard.ts'; import { @@ -25,18 +26,25 @@ vi.mock('@/src/services/clipboard.ts', () => ({ copyTextToClipboard: vi.fn(), })); +const focusResourceMock = vi.fn(); + function renderMessage( message: EditorAgentMessage, onReferenceImage?: Parameters[0]['onReferenceImage'], ) { return render( - , + + + , ); } @@ -124,7 +132,10 @@ describe('MessageBubble', () => { it('closes the previous menu before another right click opens a new one', () => { render( - <> + { onConfirmToolCall={vi.fn()} onCancelToolCall={vi.fn()} /> - , + , ); const messages = screen.getAllByLabelText('Agent消息'); @@ -369,11 +380,76 @@ describe('MessageBubble', () => { ); fireEvent.contextMenu(imageCards[1]!, { clientX: 50, clientY: 60 }); + expect(screen.queryByRole('menuitem', { name: '在画布中定位' })).toBeNull(); expect(screen.getByRole('menuitem', { name: '引用' })).toBeTruthy(); expect(screen.getByRole('menuitem', { name: '复制图片' })).toBeTruthy(); expect(screen.getByRole('menuitem', { name: '下载图片' })).toBeTruthy(); }); + it('focuses generated image, video and audio only from their right-click menus', async () => { + const { container } = renderMessage({ + id: 9, + role: 'system', + text: 'internal tool result', + attachments: [], + toolCall: { + toolName: 'generate_media', + status: 'completed', + args: {}, + displayArgs: { + stringArgs: [], + imageArgs: [], + extras: { priceMudPoints: 1 }, + }, + images: [ + { + resourceId: ' resource-image ', + imageSrc: '/generated-editor-images/image.png', + }, + ], + videos: [ + { + resourceId: 'resource-video', + videoSrc: '/generated-editor-videos/video.mp4', + }, + ], + audios: [ + { + resourceId: 'resource-audio', + audioSrc: '/generated-editor-audios/audio.mp3', + }, + ], + }, + createdAt: '2026-07-20T00:00:00Z', + }); + await waitFor(() => expect(container.querySelector('video')).toBeTruthy()); + + const imageCard = container.querySelector('.grid.grid-cols-3 > div'); + const video = container.querySelector('video'); + const audio = container.querySelector('audio'); + fireEvent.click(imageCard!); + fireEvent.click(video!); + fireEvent.click(audio!); + expect(focusResourceMock).not.toHaveBeenCalled(); + + const targets = [ + [imageCard, 'resource-image'], + [video!.parentElement, 'resource-video'], + [audio!.parentElement, 'resource-audio'], + ] as const; + for (const [target, resourceId] of targets) { + fireEvent.contextMenu(target!, { clientX: 30, clientY: 40 }); + fireEvent.click(screen.getByRole('menuitem', { name: '在画布中定位' })); + expect(focusResourceMock).toHaveBeenLastCalledWith(resourceId); + await waitFor(() => + expect( + screen.queryByRole('menu', { name: '消息素材右键菜单' }), + ).toBeNull(), + ); + } + expect(focusResourceMock).toHaveBeenCalledTimes(3); + }); + it('exports right-click images through the native HostBridge', async () => { const invoke = vi.fn( async (_command: string, args?: Record) => { diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx index c5f287a25..cab696542 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx @@ -9,6 +9,7 @@ import { } from '@/src/components/image-editor/EditorAgentConversation/common.ts'; import { PendingToolCall } from '@/src/components/image-editor/EditorAgentConversation/PendingToolCall.tsx'; import ToolCallView from '@/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx'; +import { useImageCanvasActions } from '@/src/components/image-editor/ImageCanvasActionsContext.ts'; import { MessageBubbleRightClickMenu } from './MessageBubbleRightClickMenu.tsx'; import { useRightClickMenu } from './useRightClickMenu.ts'; @@ -73,12 +74,16 @@ export function MessageBubble({ onJobCompleted, onReferenceImage, }: MessageBubbleProps) { + const { focusResource } = useImageCanvasActions(); const { rightClickMenu, openRightClickMenu, closeRightClickMenu, runRightClickAction, - } = useRightClickMenu({ onReferenceImage }); + } = useRightClickMenu({ + onReferenceImage, + onFocusResource: focusResource, + }); const systemErrorText = message.role === 'system' && !message.toolCall && diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx index 743813b4f..9b98c29ed 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx @@ -34,31 +34,36 @@ function actionLabel({ resultAction: EditorAgentRightClickAction | null; result: 'success' | 'error' | null; }) { + let actionState: 'pending' | 'success' | 'error' | null = null; if (pendingAction === action) { - return action === EditorAgentRightClickAction.DownloadAsset - ? '下载中' - : action === EditorAgentRightClickAction.ReferenceImage - ? '引用中' - : '复制中'; + actionState = 'pending'; + } else if (resultAction === action) { + actionState = result; } - if (resultAction !== action) { + + if (!actionState) { return idleLabel; } - if (result === 'success') { - return action === EditorAgentRightClickAction.DownloadAsset - ? '已下载' - : action === EditorAgentRightClickAction.ReferenceImage - ? '已引用' - : '已复制'; + + switch (action) { + case EditorAgentRightClickAction.FocusCanvas: + if (actionState === 'pending') return '定位中'; + if (actionState === 'success') return '已定位'; + return '定位失败'; + case EditorAgentRightClickAction.DownloadAsset: + if (actionState === 'pending') return '下载中'; + if (actionState === 'success') return '已下载'; + return '下载失败'; + case EditorAgentRightClickAction.ReferenceImage: + if (actionState === 'pending') return '引用中'; + if (actionState === 'success') return '已引用'; + return '引用失败'; + case EditorAgentRightClickAction.CopyText: + case EditorAgentRightClickAction.CopyImage: + if (actionState === 'pending') return '复制中'; + if (actionState === 'success') return '已复制'; + return '复制失败'; } - if (result === 'error') { - return action === EditorAgentRightClickAction.DownloadAsset - ? '下载失败' - : action === EditorAgentRightClickAction.ReferenceImage - ? '引用失败' - : '复制失败'; - } - return idleLabel; } function assetDownloadLabel(asset: EditorAgentContextAsset) { @@ -175,6 +180,23 @@ export function MessageBubbleRightClickMenu({ ) : ( <> + {target.asset.kind === 'generated_media' && + target.asset.resourceId?.trim() ? ( + + ) : null} {target.asset.mediaType === 'image' ? ( <> {contextAssetMediaSrc(target.asset).trim() ? ( diff --git a/src/components/image-editor/EditorAgentConversation/ToolCallView.test.tsx b/src/components/image-editor/EditorAgentConversation/ToolCallView.test.tsx index a9338a306..778e8a7e1 100644 --- a/src/components/image-editor/EditorAgentConversation/ToolCallView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/ToolCallView.test.tsx @@ -1,6 +1,12 @@ /* @vitest-environment jsdom */ -import { act, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import type { ReactElement, ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -10,6 +16,7 @@ import { ImageCanvasActionsProvider } from '@/src/components/image-editor/ImageC import ToolCallView from './ToolCallView.tsx'; const getExternalGenerationJobStatusMock = vi.hoisted(() => vi.fn()); +const focusResourceMock = vi.fn(); const refreshCanvasMock = vi.fn(); vi.mock('@/src/services/external-generation', () => ({ @@ -60,7 +67,10 @@ function createCompletedJobResponse(jobId: string) { function renderWithCanvasActions(ui: ReactElement) { return render(ui, { wrapper: ({ children }: { children: ReactNode }) => ( - + {children} ), @@ -70,6 +80,7 @@ function renderWithCanvasActions(ui: ReactElement) { describe('ToolCallView', () => { beforeEach(() => { getExternalGenerationJobStatusMock.mockReset(); + focusResourceMock.mockReset(); refreshCanvasMock.mockReset(); }); @@ -165,4 +176,88 @@ describe('ToolCallView', () => { expect(refreshCanvasMock).toHaveBeenCalledTimes(1); }); + it('keeps media clicks passive and exposes canvas resources to the right-click menu', () => { + const onRightClickMenu = vi.fn(); + const { container } = renderWithCanvasActions( + div'); + const video = container.querySelector('video'); + const audio = container.querySelector('audio'); + expect(imageCards).toHaveLength(2); + expect(video).toBeTruthy(); + expect(audio).toBeTruthy(); + expect(imageCards[0]!.getAttribute('role')).toBeNull(); + expect(imageCards[0]!.getAttribute('tabindex')).toBeNull(); + + fireEvent.click(imageCards[0]!); + fireEvent.keyDown(imageCards[0]!, { key: 'Enter' }); + fireEvent.keyDown(imageCards[0]!, { key: ' ', code: 'Space' }); + fireEvent.click(video!); + fireEvent.click(audio!); + expect(focusResourceMock).not.toHaveBeenCalled(); + expect( + screen.queryByRole('button', { + name: '在画布中定位Agent生成图片-1', + }), + ).toBeNull(); + expect(video!.getAttribute('aria-label')).toBe('Agent生成视频-1'); + expect(audio!.getAttribute('aria-label')).toBe('Agent生成音频-1'); + + fireEvent.contextMenu(imageCards[0]!); + fireEvent.contextMenu(video!.parentElement!); + fireEvent.contextMenu(audio!.parentElement!); + fireEvent.contextMenu(imageCards[1]!); + + expect(onRightClickMenu.mock.calls.map((call) => call[1])).toEqual([ + expect.objectContaining({ + kind: 'generated_media', + mediaType: 'image', + resourceId: 'resource-image', + }), + expect.objectContaining({ + kind: 'generated_media', + mediaType: 'video', + resourceId: 'resource-video', + }), + expect.objectContaining({ + kind: 'generated_media', + mediaType: 'audio', + resourceId: 'resource-audio', + }), + expect.objectContaining({ + kind: 'generated_media', + mediaType: 'image', + resourceId: null, + }), + ]); + }); }); diff --git a/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx b/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx index 9732d3b8b..63462e073 100644 --- a/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx +++ b/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx @@ -116,104 +116,118 @@ function ToolCallView({ ) : null} {toolCall.images.length ? (
- {toolCall.images.map((image, index) => ( -
- onRightClickMenu(event, { - kind: 'generated_media', - mediaType: 'image', - mediaSrc: image.imageSrc, - objectKey: image.objectKey, - suggestedFileName: `Agent生成图片-${index + 1}`, - }) - : undefined - } - > - -
- ))} + {toolCall.images.map((image, index) => { + const resourceId = image.resourceId?.trim() || null; + return ( +
+ onRightClickMenu(event, { + kind: 'generated_media', + mediaType: 'image', + mediaSrc: image.imageSrc, + objectKey: image.objectKey, + resourceId, + suggestedFileName: `Agent生成图片-${index + 1}`, + }) + : undefined + } + > + +
+ ); + })}
) : null} {videos.length ? (
- {videos.map((video, index) => ( -
- onRightClickMenu(event, { - kind: 'generated_media', - mediaType: 'video', - mediaSrc: video.videoSrc, - objectKey: video.objectKey, - suggestedFileName: `Agent生成视频-${index + 1}`, - }) - : undefined - } - > - { + const resourceId = video.resourceId?.trim() || null; + return ( +
+ onRightClickMenu(event, { + kind: 'generated_media', + mediaType: 'video', + mediaSrc: video.videoSrc, + objectKey: video.objectKey, + resourceId, + suggestedFileName: `Agent生成视频-${index + 1}`, + }) + : undefined } - poster={video.thumbnailSrc ?? undefined} - controls - playsInline - preload="metadata" - className="max-h-56 w-full bg-black object-contain" - /> -
- ))} + > + +
+ ); + })}
) : null} {audios.length ? (
- {audios.map((audio, index) => ( -
- onRightClickMenu(event, { - kind: 'generated_media', - mediaType: 'audio', - mediaSrc: audio.audioSrc, - objectKey: audio.objectKey, - suggestedFileName: `Agent生成音频-${index + 1}`, - }) - : undefined - } - > -
+ ); + })}
) : null} diff --git a/src/components/image-editor/EditorAgentConversation/common.ts b/src/components/image-editor/EditorAgentConversation/common.ts index fbcf716ce..071a51094 100644 --- a/src/components/image-editor/EditorAgentConversation/common.ts +++ b/src/components/image-editor/EditorAgentConversation/common.ts @@ -6,6 +6,7 @@ export enum EditorAgentRightClickAction { CopyText = 'copy_text', CopyImage = 'copy_image', ReferenceImage = 'reference_image', + FocusCanvas = 'focus_canvas', DownloadAsset = 'download_asset', } @@ -20,6 +21,7 @@ export type EditorAgentContextAsset = mediaType: 'image' | 'video' | 'audio'; mediaSrc: string; objectKey?: string | null; + resourceId?: string | null; suggestedFileName: string; }; diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx index 3f1aa8c3e..8a57e803a 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx @@ -23,6 +23,7 @@ import { useEditorAgentConversation, } from './useEditorAgentConversation.ts'; +const focusResourceMock = vi.fn(); const refreshCanvasMock = vi.fn(); function ImageCanvasActionsTestWrapper({ @@ -31,7 +32,10 @@ function ImageCanvasActionsTestWrapper({ children: ReactNode; }) { return ( - + {children} ); diff --git a/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts b/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts index ae04351bf..294e1e168 100644 --- a/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts +++ b/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts @@ -232,10 +232,27 @@ async function downloadAsset(asset: EditorAgentContextAsset) { } } +function focusAssetOnCanvas( + asset: EditorAgentContextAsset, + onFocusResource?: (resourceId: string) => void, +) { + if (asset.kind !== 'generated_media' || !onFocusResource) { + return false; + } + const resourceId = asset.resourceId?.trim(); + if (!resourceId) { + return false; + } + onFocusResource(resourceId); + return true; +} + export function useRightClickMenu({ onReferenceImage, + onFocusResource, }: { onReferenceImage?: (asset: EditorAgentContextAsset) => boolean; + onFocusResource?: (resourceId: string) => void; } = {}) { const [rightClickMenu, setRightClickMenu] = useState(null); @@ -289,10 +306,13 @@ export function useRightClickMenu({ target.asset.mediaType === 'image' && contextAssetMediaSrc(target.asset).trim() ? (onReferenceImage?.(target.asset) ?? false) - : action === EditorAgentRightClickAction.DownloadAsset && + : action === EditorAgentRightClickAction.FocusCanvas && target.kind === 'asset' - ? await downloadAsset(target.asset) - : false; + ? focusAssetOnCanvas(target.asset, onFocusResource) + : action === EditorAgentRightClickAction.DownloadAsset && + target.kind === 'asset' + ? await downloadAsset(target.asset) + : false; setRightClickMenu((current) => current?.target === target && current.pendingAction === action ? succeeded @@ -306,7 +326,7 @@ export function useRightClickMenu({ : current, ); }, - [onReferenceImage, rightClickMenu], + [onFocusResource, onReferenceImage, rightClickMenu], ); return { diff --git a/src/components/image-editor/ImageCanvasActionsContext.test.tsx b/src/components/image-editor/ImageCanvasActionsContext.test.tsx index 673acf789..7269b16ef 100644 --- a/src/components/image-editor/ImageCanvasActionsContext.test.tsx +++ b/src/components/image-editor/ImageCanvasActionsContext.test.tsx @@ -11,19 +11,25 @@ import { ImageCanvasActionsProvider } from './ImageCanvasActionsProvider'; describe('ImageCanvasActionsContext', () => { it('exposes the editor-scoped canvas actions', () => { + const focusResource = vi.fn(); const refreshCanvas = vi.fn(); const { result } = renderHook(useImageCanvasActions, { wrapper: ({ children }: { children: ReactNode }) => ( - + {children} ), }); act(() => { + result.current.focusResource('resource-a'); result.current.refreshCanvas(); }); + expect(focusResource).toHaveBeenCalledWith('resource-a'); expect(refreshCanvas).toHaveBeenCalledTimes(1); }); diff --git a/src/components/image-editor/ImageCanvasActionsContext.ts b/src/components/image-editor/ImageCanvasActionsContext.ts index 99d681712..963632896 100644 --- a/src/components/image-editor/ImageCanvasActionsContext.ts +++ b/src/components/image-editor/ImageCanvasActionsContext.ts @@ -1,6 +1,7 @@ import { createContext, useContext } from 'react'; export type ImageCanvasActions = { + focusResource: (resourceId: string) => void; refreshCanvas: () => void; }; diff --git a/src/components/image-editor/ImageCanvasActionsProvider.tsx b/src/components/image-editor/ImageCanvasActionsProvider.tsx index 222d0a69e..6849516f0 100644 --- a/src/components/image-editor/ImageCanvasActionsProvider.tsx +++ b/src/components/image-editor/ImageCanvasActionsProvider.tsx @@ -7,14 +7,16 @@ import { export function ImageCanvasActionsProvider({ children, + focusResource, refreshCanvas, }: { children: ReactNode; + focusResource: ImageCanvasActions['focusResource']; refreshCanvas: ImageCanvasActions['refreshCanvas']; }) { const actions = useMemo( - () => ({ refreshCanvas }), - [refreshCanvas], + () => ({ focusResource, refreshCanvas }), + [focusResource, refreshCanvas], ); return ( diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index bdf6f63fd..40757a98b 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -20,7 +20,10 @@ import type { ImageCanvasTopbarViewProps } from './ImageCanvasTopbarView'; function render(ui: ReactElement) { return testingLibraryRender(ui, { wrapper: ({ children }: { children: ReactNode }) => ( - + {children} ), diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 9c171e9e1..429edb391 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -2545,9 +2545,15 @@ describe('ImageCanvasEditorView', () => { expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull(); }); - it('renders Agent generation thumbnails as passive previews', async () => { + it('smoothly focuses an Agent generation resource without changing canvas selection or panels', async () => { + const rafCallbacks: FrameRequestCallback[] = []; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); enableEditorAgentSidebarForTest(); - const detail = createEditorAgentDetailWithGeneration('resource-puzzle'); + const detail = createEditorAgentDetailWithGeneration('resource-big-fish'); listEditorAgentConversationsMock.mockResolvedValueOnce([ createEditorAgentConversationSummary(), ]); @@ -2559,10 +2565,44 @@ describe('ImageCanvasEditorView', () => { const messageLog = await screen.findByRole('log', { name: '画布 Agent 消息流', }); + const world = screen.getByTestId('image-canvas-world') as HTMLElement; + const puzzleLayer = screen + .getByAltText('画布图片:拼图素材') + .closest('button')!; + const bigFishLayer = screen + .getByAltText('画布图片:大鱼素材') + .closest('button')!; + const initialTransform = world.style.transform; expect( - within(messageLog).queryByRole('button', { name: '生成结果' }), - ).toBeNull(); + puzzleLayer.classList.contains('image-canvas-editor__layer--selected'), + ).toBe(true); + expect( + bigFishLayer.classList.contains('image-canvas-editor__layer--selected'), + ).toBe(false); + fireEvent.click( + within(messageLog).getByRole('button', { + name: '在画布中定位Agent生成图片-1', + }), + ); + expect(world.style.transform).toBe(initialTransform); + + act(() => rafCallbacks.shift()?.(0)); + act(() => rafCallbacks.shift()?.(210)); + expect(world.style.transform).not.toBe(initialTransform); + expect(world.style.transform).not.toBe( + 'translate(-840px, -246.5px) scale(1)', + ); + + act(() => rafCallbacks.shift()?.(420)); + expect(world.style.transform).toBe('translate(-840px, -246.5px) scale(1)'); + expect(screen.getByLabelText('发送给画布 Agent')).toBeTruthy(); + expect( + puzzleLayer.classList.contains('image-canvas-editor__layer--selected'), + ).toBe(true); + expect( + bigFishLayer.classList.contains('image-canvas-editor__layer--selected'), + ).toBe(false); expect(within(messageLog).queryByText('gpt-image-2')).toBeNull(); expect(loadEditorProjectMock).not.toHaveBeenCalled(); }); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index a8791887e..277b529da 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -454,6 +454,7 @@ export function ImageCanvasEditorView({ const { viewport, setViewport, + animateViewportTo, canvasSize, minimapModel, updateScaleFromCenter, @@ -876,7 +877,7 @@ export function ImageCanvasEditorView({ const focusCanvasBounds = useCallback( ( bounds: { x: number; y: number; width: number; height: number }, - options: { reserveDialogSpace?: boolean } = {}, + options: { reserveDialogSpace?: boolean; animate?: boolean } = {}, ) => { const nextViewport = fitViewportToBounds({ bounds, @@ -891,9 +892,29 @@ export function ImageCanvasEditorView({ }, padding: TASK_FOCUS_PADDING, }); + if (options.animate) { + animateViewportTo(nextViewport); + return; + } setViewport(nextViewport); }, - [canvasSize, setViewport], + [animateViewportTo, canvasSize, setViewport], + ); + const focusCanvasResource = useCallback( + (resourceId: string) => { + const normalizedResourceId = resourceId.trim(); + if (!normalizedResourceId) { + return; + } + const targetLayer = layersRef.current.find( + (layer) => layer.resourceId === normalizedResourceId, + ); + if (!targetLayer) { + return; + } + focusCanvasBounds(targetLayer, { animate: true }); + }, + [focusCanvasBounds], ); const focusCanvasLayerById = useCallback( (layerId: string) => { @@ -2457,7 +2478,10 @@ export function ImageCanvasEditorView({ }; return ( - + ( - + {children} ), @@ -44,6 +48,7 @@ const listExternalGenerationTasksMock = vi.mocked(listExternalGenerationTasks); beforeEach(() => { useImageCanvasContextStore.getState().setProjectId('project-1'); + focusResourceMock.mockReset(); refreshCanvasMock.mockReset(); listExternalGenerationTasksMock.mockClear(); listExternalGenerationTasksMock.mockResolvedValue({ diff --git a/src/components/image-editor/useImageCanvasViewportControls.test.tsx b/src/components/image-editor/useImageCanvasViewportControls.test.tsx index 6c4fed9f9..cc25c8cf4 100644 --- a/src/components/image-editor/useImageCanvasViewportControls.test.tsx +++ b/src/components/image-editor/useImageCanvasViewportControls.test.tsx @@ -116,6 +116,7 @@ function expectViewport(viewport: CanvasViewport, expected: CanvasViewport) { describe('useImageCanvasViewportControls', () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); document.body.replaceChildren(); }); @@ -158,6 +159,91 @@ describe('useImageCanvasViewportControls', () => { expect(captureCanvasHistory).toHaveBeenCalledTimes(2); }); + it('animates, retargets from the current frame and lets manual input cancel the transition', () => { + const rafCallbacks = new Map(); + let nextFrameId = 0; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + nextFrameId += 1; + rafCallbacks.set(nextFrameId, callback); + return nextFrameId; + }); + const cancelAnimationFrameSpy = vi + .spyOn(window, 'cancelAnimationFrame') + .mockImplementation((frameId) => { + rafCallbacks.delete(frameId); + }); + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: false })) as unknown as typeof window.matchMedia, + ); + const { result, viewportElement, captureCanvasHistory } = + renderViewportControls(); + const targetViewport = { x: 120, y: -80, scale: 1.25 }; + const runNextFrame = (timestamp: number) => { + const entry = rafCallbacks.entries().next().value as + [number, FrameRequestCallback] | undefined; + expect(entry).toBeTruthy(); + if (!entry) { + return; + } + rafCallbacks.delete(entry[0]); + entry[1](timestamp); + }; + + act(() => { + result.current.animateViewportTo(targetViewport); + }); + expectViewport(result.current.viewport, { + x: -260, + y: 70, + scale: 0.5, + }); + + act(() => runNextFrame(0)); + act(() => runNextFrame(210)); + expect(result.current.viewport.x).toBeGreaterThan(-260); + expect(result.current.viewport.x).toBeLessThan(120); + expect(result.current.viewport.scale).toBeGreaterThan(0.5); + expect(result.current.viewport.scale).toBeLessThan(1.25); + + const retargetedViewport = { x: 420, y: 260, scale: 1.6 }; + act(() => { + result.current.animateViewportTo(retargetedViewport); + }); + expect(cancelAnimationFrameSpy).toHaveBeenCalled(); + act(() => runNextFrame(300)); + act(() => runNextFrame(720)); + expectViewport(result.current.viewport, retargetedViewport); + expect(captureCanvasHistory).not.toHaveBeenCalled(); + + act(() => { + result.current.animateViewportTo({ x: 600, y: 400, scale: 2 }); + }); + act(() => runNextFrame(800)); + act(() => { + fireEvent.wheel(viewportElement, { deltaY: 40 }); + }); + expect(cancelAnimationFrameSpy).toHaveBeenCalled(); + expect(rafCallbacks.size).toBe(0); + }); + + it('finishes viewport animation immediately when reduced motion is requested', () => { + const requestAnimationFrameSpy = vi.spyOn(window, 'requestAnimationFrame'); + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: true })) as unknown as typeof window.matchMedia, + ); + const { result } = renderViewportControls(); + const targetViewport = { x: 20, y: 30, scale: 0.8 }; + + act(() => { + result.current.animateViewportTo(targetViewport); + }); + + expectViewport(result.current.viewport, targetViewport); + expect(requestAnimationFrameSpy).not.toHaveBeenCalled(); + }); + it('keeps ordinary mouse wheel vertical and maps Shift wheel to horizontal', () => { const { result, viewportElement } = renderViewportControls(); diff --git a/src/components/image-editor/useImageCanvasViewportControls.ts b/src/components/image-editor/useImageCanvasViewportControls.ts index dddbe6784..6487a5881 100644 --- a/src/components/image-editor/useImageCanvasViewportControls.ts +++ b/src/components/image-editor/useImageCanvasViewportControls.ts @@ -1,5 +1,7 @@ import { + type Dispatch, type RefObject, + type SetStateAction, useCallback, useEffect, useMemo, @@ -34,6 +36,31 @@ export const DEFAULT_IMAGE_CANVAS_VIEWPORT: CanvasViewport = { scale: 0.5, }; +export const IMAGE_CANVAS_VIEWPORT_ANIMATION_DURATION_MS = 420; + +function easeOutCubic(progress: number) { + return 1 - (1 - progress) ** 3; +} + +function interpolateViewport( + start: CanvasViewport, + target: CanvasViewport, + progress: number, +): CanvasViewport { + return { + x: start.x + (target.x - start.x) * progress, + y: start.y + (target.y - start.y) * progress, + scale: start.scale + (target.scale - start.scale) * progress, + }; +} + +function prefersReducedCanvasMotion() { + return ( + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); +} + function isCanvasWheelInteractionTarget(target: EventTarget | null) { return ( target instanceof Element && @@ -54,9 +81,11 @@ export function useImageCanvasViewportControls({ layers, captureCanvasHistory, }: UseImageCanvasViewportControlsOptions) { - const [viewport, setViewport] = useState( + const [viewport, setViewportState] = useState( DEFAULT_IMAGE_CANVAS_VIEWPORT, ); + const viewportRef = useRef(viewport); + const viewportAnimationFrameRef = useRef(null); const [canvasSize, setCanvasSize] = useState(DEFAULT_CANVAS_SIZE); const pendingMinimapDragRef = useRef<{ dragState: Extract; @@ -65,6 +94,66 @@ export function useImageCanvasViewportControls({ } | null>(null); const minimapDragFrameRef = useRef(null); + viewportRef.current = viewport; + + const cancelViewportAnimation = useCallback(() => { + if (viewportAnimationFrameRef.current === null) { + return; + } + window.cancelAnimationFrame(viewportAnimationFrameRef.current); + viewportAnimationFrameRef.current = null; + }, []); + + const setViewport = useCallback>>( + (nextViewport) => { + cancelViewportAnimation(); + const resolvedViewport = + typeof nextViewport === 'function' + ? nextViewport(viewportRef.current) + : nextViewport; + viewportRef.current = resolvedViewport; + setViewportState(resolvedViewport); + }, + [cancelViewportAnimation], + ); + + const animateViewportTo = useCallback( + (targetViewport: CanvasViewport) => { + cancelViewportAnimation(); + if (prefersReducedCanvasMotion()) { + viewportRef.current = targetViewport; + setViewportState(targetViewport); + return; + } + + const startViewport = viewportRef.current; + let startedAt: number | null = null; + const step = (timestamp: number) => { + startedAt ??= timestamp; + const elapsedMs = Math.max(0, timestamp - startedAt); + const progress = Math.min( + 1, + elapsedMs / IMAGE_CANVAS_VIEWPORT_ANIMATION_DURATION_MS, + ); + const nextViewport = interpolateViewport( + startViewport, + targetViewport, + easeOutCubic(progress), + ); + viewportRef.current = nextViewport; + setViewportState(nextViewport); + if (progress >= 1) { + viewportAnimationFrameRef.current = null; + return; + } + viewportAnimationFrameRef.current = window.requestAnimationFrame(step); + }; + + viewportAnimationFrameRef.current = window.requestAnimationFrame(step); + }, + [cancelViewportAnimation], + ); + const minimapModel = useMemo( () => createMinimapModel({ layers, viewport, canvasSize }), [canvasSize, layers, viewport], @@ -122,7 +211,13 @@ export function useImageCanvasViewportControls({ }), ); }, - [canvasSize.height, canvasSize.width, canvasViewportRef, captureCanvasHistory], + [ + canvasSize.height, + canvasSize.width, + canvasViewportRef, + captureCanvasHistory, + setViewport, + ], ); const fitLayers = useCallback( @@ -143,7 +238,7 @@ export function useImageCanvasViewportControls({ } setViewport(nextViewport); }, - [captureCanvasHistory, canvasSize, layers], + [captureCanvasHistory, canvasSize, layers, setViewport], ); const resolveCanvasPoint = useCallback( @@ -200,7 +295,7 @@ export function useImageCanvasViewportControls({ }), ); }, - [canvasSize, minimapModel], + [canvasSize, minimapModel, setViewport], ); const flushMinimapViewportDrag = useCallback(() => { @@ -219,7 +314,7 @@ export function useImageCanvasViewportControls({ y: pendingDrag.clientY, }), ); - }, []); + }, [setViewport]); const updateViewportFromMinimapDrag = useCallback( ( @@ -246,18 +341,19 @@ export function useImageCanvasViewportControls({ ); }); }, - [], + [setViewport], ); useEffect( () => () => { + cancelViewportAnimation(); if (minimapDragFrameRef.current !== null) { window.cancelAnimationFrame(minimapDragFrameRef.current); minimapDragFrameRef.current = null; } pendingMinimapDragRef.current = null; }, - [], + [cancelViewportAnimation], ); const handleNativeWheel = useCallback( @@ -302,7 +398,7 @@ export function useImageCanvasViewportControls({ }), ); }, - [canvasViewportRef], + [canvasViewportRef, setViewport], ); useEffect(() => { @@ -321,6 +417,7 @@ export function useImageCanvasViewportControls({ return { viewport, setViewport, + animateViewportTo, canvasSize, minimapModel, updateScaleFromCenter, -- 2.52.0 From 65fabe51abfe50f81bd71e16d71214a25bd1e30e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 31 Jul 2026 13:02:05 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E5=AA=92=E4=BD=93=E5=AE=9A=E4=BD=8D=E4=BA=A4=E4=BA=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按现役右键菜单交互打开生成媒体的画布定位入口 点击定位菜单项后验证视口平滑聚焦及画布选择状态保持不变 --- .../image-editor/ImageCanvasEditorView.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 429edb391..0182d2329 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -2580,11 +2580,11 @@ describe('ImageCanvasEditorView', () => { expect( bigFishLayer.classList.contains('image-canvas-editor__layer--selected'), ).toBe(false); - fireEvent.click( - within(messageLog).getByRole('button', { - name: '在画布中定位Agent生成图片-1', - }), + fireEvent.contextMenu( + within(messageLog).getByRole('presentation'), + { clientX: 30, clientY: 40 }, ); + fireEvent.click(screen.getByRole('menuitem', { name: '在画布中定位' })); expect(world.style.transform).toBe(initialTransform); act(() => rafCallbacks.shift()?.(0)); -- 2.52.0 From cfd129a0bf0da21378b2b682e3258631bc00e07d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 31 Jul 2026 13:34:29 +0800 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=AE=9A=E4=BD=8D=E7=8A=B6=E6=80=81=E8=AF=AF?= =?UTF-8?q?=E6=8A=A5=20=E8=AE=A9=20focusResource=20=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=8C=B9=E9=85=8D=E7=BB=93=E6=9E=9C=20?= =?UTF-8?q?=E6=9C=AA=E6=89=BE=E5=88=B0=E7=94=BB=E5=B8=83=E5=9B=BE=E5=B1=82?= =?UTF-8?q?=E6=97=B6=E5=90=91=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E5=A4=B1=E8=B4=A5=E7=8A=B6=E6=80=81=20=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E8=B5=84=E6=BA=90=E5=AE=9A=E4=BD=8D=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E4=B8=8E=E5=A4=B1=E8=B4=A5=E7=9A=84=E5=9B=9E=E5=BD=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EditorAgentConversation/MessageBubble.test.tsx | 8 ++++++++ .../EditorAgentConversation/useRightClickMenu.ts | 8 ++++---- .../image-editor/ImageCanvasActionsContext.test.tsx | 6 ++++-- src/components/image-editor/ImageCanvasActionsContext.ts | 2 +- src/components/image-editor/ImageCanvasEditorView.tsx | 5 +++-- 5 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx index cc5bfbe51..9c5371d5d 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx @@ -51,6 +51,7 @@ function renderMessage( describe('MessageBubble', () => { beforeEach(() => { vi.clearAllMocks(); + focusResourceMock.mockReturnValue({ found: true }); vi.mocked(copyTextToClipboard).mockResolvedValue(true); }); @@ -448,6 +449,13 @@ describe('MessageBubble', () => { ); } expect(focusResourceMock).toHaveBeenCalledTimes(3); + + focusResourceMock.mockReturnValueOnce({ found: false }); + fireEvent.contextMenu(imageCard!, { clientX: 30, clientY: 40 }); + fireEvent.click(screen.getByRole('menuitem', { name: '在画布中定位' })); + + expect(focusResourceMock).toHaveBeenLastCalledWith('resource-image'); + expect(screen.getByRole('menuitem', { name: '定位失败' })).toBeTruthy(); }); it('exports right-click images through the native HostBridge', async () => { diff --git a/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts b/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts index 294e1e168..e26c667dd 100644 --- a/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts +++ b/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts @@ -4,6 +4,7 @@ import { useState, } from 'react'; +import type { ImageCanvasActions } from '@/src/components/image-editor/ImageCanvasActionsContext.ts'; import { readAssetBytes } from '@/src/services/assetReadUrlService.ts'; import { copyTextToClipboard } from '@/src/services/clipboard.ts'; import { @@ -234,7 +235,7 @@ async function downloadAsset(asset: EditorAgentContextAsset) { function focusAssetOnCanvas( asset: EditorAgentContextAsset, - onFocusResource?: (resourceId: string) => void, + onFocusResource?: ImageCanvasActions['focusResource'], ) { if (asset.kind !== 'generated_media' || !onFocusResource) { return false; @@ -243,8 +244,7 @@ function focusAssetOnCanvas( if (!resourceId) { return false; } - onFocusResource(resourceId); - return true; + return onFocusResource(resourceId).found; } export function useRightClickMenu({ @@ -252,7 +252,7 @@ export function useRightClickMenu({ onFocusResource, }: { onReferenceImage?: (asset: EditorAgentContextAsset) => boolean; - onFocusResource?: (resourceId: string) => void; + onFocusResource?: ImageCanvasActions['focusResource']; } = {}) { const [rightClickMenu, setRightClickMenu] = useState(null); diff --git a/src/components/image-editor/ImageCanvasActionsContext.test.tsx b/src/components/image-editor/ImageCanvasActionsContext.test.tsx index 7269b16ef..a383b68dc 100644 --- a/src/components/image-editor/ImageCanvasActionsContext.test.tsx +++ b/src/components/image-editor/ImageCanvasActionsContext.test.tsx @@ -11,7 +11,7 @@ import { ImageCanvasActionsProvider } from './ImageCanvasActionsProvider'; describe('ImageCanvasActionsContext', () => { it('exposes the editor-scoped canvas actions', () => { - const focusResource = vi.fn(); + const focusResource = vi.fn(() => ({ found: true })); const refreshCanvas = vi.fn(); const { result } = renderHook(useImageCanvasActions, { wrapper: ({ children }: { children: ReactNode }) => ( @@ -24,11 +24,13 @@ describe('ImageCanvasActionsContext', () => { ), }); + let focusResult: { found: boolean } | undefined; act(() => { - result.current.focusResource('resource-a'); + focusResult = result.current.focusResource('resource-a'); result.current.refreshCanvas(); }); + expect(focusResult).toEqual({ found: true }); expect(focusResource).toHaveBeenCalledWith('resource-a'); expect(refreshCanvas).toHaveBeenCalledTimes(1); }); diff --git a/src/components/image-editor/ImageCanvasActionsContext.ts b/src/components/image-editor/ImageCanvasActionsContext.ts index 963632896..b989b66e9 100644 --- a/src/components/image-editor/ImageCanvasActionsContext.ts +++ b/src/components/image-editor/ImageCanvasActionsContext.ts @@ -1,7 +1,7 @@ import { createContext, useContext } from 'react'; export type ImageCanvasActions = { - focusResource: (resourceId: string) => void; + focusResource: (resourceId: string) => { found: boolean }; refreshCanvas: () => void; }; diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 277b529da..4f5f39d17 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -904,15 +904,16 @@ export function ImageCanvasEditorView({ (resourceId: string) => { const normalizedResourceId = resourceId.trim(); if (!normalizedResourceId) { - return; + return { found: false }; } const targetLayer = layersRef.current.find( (layer) => layer.resourceId === normalizedResourceId, ); if (!targetLayer) { - return; + return { found: false }; } focusCanvasBounds(targetLayer, { animate: true }); + return { found: true }; }, [focusCanvasBounds], ); -- 2.52.0 From aae4971a1bec37ea1f606e92fca0614963fab6fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 31 Jul 2026 13:38:12 +0800 Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E5=88=86=E5=8F=91=20=E4=BD=BF=E7=94=A8=20switch=20?= =?UTF-8?q?=E6=8C=89=E5=8A=A8=E4=BD=9C=E7=B1=BB=E5=9E=8B=E5=88=86=E5=8F=91?= =?UTF-8?q?=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=E6=93=8D=E4=BD=9C=20?= =?UTF-8?q?=E5=9C=A8=E5=90=84=E5=8A=A8=E4=BD=9C=E5=88=86=E6=94=AF=E5=86=85?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E7=9B=AE=E6=A0=87=E7=B1=BB=E5=9E=8B=E5=92=8C?= =?UTF-8?q?=E5=AA=92=E4=BD=93=E7=B1=BB=E5=9E=8B=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E7=8A=B6=E6=80=81=E6=9B=B4=E6=96=B0=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E5=B5=8C=E5=A5=97=E4=B8=89=E5=85=83=E8=A1=A8=E8=BE=BE?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../useRightClickMenu.ts | 78 +++++++++++-------- 1 file changed, 46 insertions(+), 32 deletions(-) diff --git a/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts b/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts index e26c667dd..1f20e6d29 100644 --- a/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts +++ b/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts @@ -293,38 +293,52 @@ export function useRightClickMenu({ } : current, ); - const succeeded = - action === EditorAgentRightClickAction.CopyText && - target.kind === 'text' - ? await copyTextToClipboard(target.text) - : action === EditorAgentRightClickAction.CopyImage && - target.kind === 'asset' && - target.asset.mediaType === 'image' - ? await copyAssetImage(target.asset) - : action === EditorAgentRightClickAction.ReferenceImage && - target.kind === 'asset' && - target.asset.mediaType === 'image' && - contextAssetMediaSrc(target.asset).trim() - ? (onReferenceImage?.(target.asset) ?? false) - : action === EditorAgentRightClickAction.FocusCanvas && - target.kind === 'asset' - ? focusAssetOnCanvas(target.asset, onFocusResource) - : action === EditorAgentRightClickAction.DownloadAsset && - target.kind === 'asset' - ? await downloadAsset(target.asset) - : false; - setRightClickMenu((current) => - current?.target === target && current.pendingAction === action - ? succeeded - ? null - : { - ...current, - pendingAction: null, - resultAction: action, - result: 'error', - } - : current, - ); + let succeeded = false; + switch (action) { + case EditorAgentRightClickAction.CopyText: + if (target.kind === 'text') { + succeeded = await copyTextToClipboard(target.text); + } + break; + case EditorAgentRightClickAction.CopyImage: + if (target.kind === 'asset' && target.asset.mediaType === 'image') { + succeeded = await copyAssetImage(target.asset); + } + break; + case EditorAgentRightClickAction.ReferenceImage: + if ( + target.kind === 'asset' && + target.asset.mediaType === 'image' && + contextAssetMediaSrc(target.asset).trim() + ) { + succeeded = onReferenceImage?.(target.asset) ?? false; + } + break; + case EditorAgentRightClickAction.FocusCanvas: + if (target.kind === 'asset') { + succeeded = focusAssetOnCanvas(target.asset, onFocusResource); + } + break; + case EditorAgentRightClickAction.DownloadAsset: + if (target.kind === 'asset') { + succeeded = await downloadAsset(target.asset); + } + break; + } + setRightClickMenu((current) => { + if (current?.target !== target || current.pendingAction !== action) { + return current; + } + if (succeeded) { + return null; + } + return { + ...current, + pendingAction: null, + resultAction: action, + result: 'error', + }; + }); }, [onFocusResource, onReferenceImage, rightClickMenu], ); -- 2.52.0 From 3292bb4c6e6be7e7b1f8d87931aba884b9e09fe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 31 Jul 2026 13:39:21 +0800 Subject: [PATCH 6/9] format --- .../useImageCanvasViewportControls.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/components/image-editor/useImageCanvasViewportControls.ts b/src/components/image-editor/useImageCanvasViewportControls.ts index 6487a5881..80f79afcd 100644 --- a/src/components/image-editor/useImageCanvasViewportControls.ts +++ b/src/components/image-editor/useImageCanvasViewportControls.ts @@ -9,9 +9,7 @@ import { useState, } from 'react'; -import { - DEFAULT_CANVAS_SIZE, -} from './ImageCanvasEditorModel'; +import { DEFAULT_CANVAS_SIZE } from './ImageCanvasEditorModel'; import type { CanvasLayer, CanvasViewport, @@ -64,9 +62,7 @@ function prefersReducedCanvasMotion() { function isCanvasWheelInteractionTarget(target: EventTarget | null) { return ( target instanceof Element && - target.closest( - 'input, textarea, select, [contenteditable="true"]', - ) !== null + target.closest('input, textarea, select, [contenteditable="true"]') !== null ); } @@ -107,11 +103,11 @@ export function useImageCanvasViewportControls({ const setViewport = useCallback>>( (nextViewport) => { cancelViewportAnimation(); - const resolvedViewport = - typeof nextViewport === 'function' - ? nextViewport(viewportRef.current) - : nextViewport; - viewportRef.current = resolvedViewport; + const resolvedViewport = + typeof nextViewport === 'function' + ? nextViewport(viewportRef.current) + : nextViewport; + viewportRef.current = resolvedViewport; setViewportState(resolvedViewport); }, [cancelViewportAnimation], -- 2.52.0 From c0705976b421b72a03790aa1711ed1de474be077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 31 Jul 2026 13:42:51 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E8=A7=86=E5=8F=A3=E6=B8=B2=E6=9F=93=E9=98=B6=E6=AE=B5=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E5=BC=95=E7=94=A8=20=E7=A7=BB=E9=99=A4=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E6=9C=9F=E9=97=B4=E5=AF=B9=20viewportRef=20=E7=9A=84?= =?UTF-8?q?=E8=B5=8B=E5=80=BC=20=E9=81=BF=E5=85=8D=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E8=A2=AB=E4=B8=A2=E5=BC=83=E5=90=8E=E6=AE=8B?= =?UTF-8?q?=E7=95=99=E6=9C=AA=E6=8F=90=E4=BA=A4=E7=9A=84=E8=A7=86=E5=8F=A3?= =?UTF-8?q?=E7=8A=B6=E6=80=81=20=E4=BF=9D=E7=95=99=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=92=8C=E5=8A=A8=E7=94=BB=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E5=BC=95=E7=94=A8=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/image-editor/useImageCanvasViewportControls.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/image-editor/useImageCanvasViewportControls.ts b/src/components/image-editor/useImageCanvasViewportControls.ts index 80f79afcd..42d50e3d6 100644 --- a/src/components/image-editor/useImageCanvasViewportControls.ts +++ b/src/components/image-editor/useImageCanvasViewportControls.ts @@ -90,8 +90,6 @@ export function useImageCanvasViewportControls({ } | null>(null); const minimapDragFrameRef = useRef(null); - viewportRef.current = viewport; - const cancelViewportAnimation = useCallback(() => { if (viewportAnimationFrameRef.current === null) { return; -- 2.52.0 From 8706e6d9af12ee10de820602368c0250b7b62e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 31 Jul 2026 15:26:59 +0800 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E5=9B=BE=E7=89=87=E7=9B=B4=E6=8E=A5=E8=81=9A=E7=84=A6?= =?UTF-8?q?=20=E9=87=8D=E6=9E=84toolcall=20status=E7=AD=89,=20=E7=94=A8swi?= =?UTF-8?q?tch=E4=BB=A3=E6=9B=BF=E5=B5=8C=E5=A5=97=E7=9A=84=E4=B8=89?= =?UTF-8?q?=E5=85=83=E8=A1=A8=E8=BE=BE=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shared-memory/decision-log.md | 10 ++ ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- .../【编辑器】画布Agent对话面板-2026-07-03.md | 6 +- .../MessageBubble.test.tsx | 7 +- .../ToolCallView.test.tsx | 6 +- .../EditorAgentConversation/ToolCallView.tsx | 101 ++++++++++++------ 6 files changed, 89 insertions(+), 43 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 3492a8bda..3c2fc3002 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -38,6 +38,16 @@ --- +## 2026-07-31 画布 Agent 图片结果单击直接定位 + +- 背景:生成图片在对话中是静态缩略图,用户需要用更直接的方式回到对应画布图层;视频和音频仍有播放、拖动和音量等原生点击交互,不能共用该行为。 +- 决策:携带有效 `resourceId` 的 Agent 生成图片在普通单击时直接调用实例级 `ImageCanvasActionsContext.focusResource(resourceId)`;无 `resourceId` 的旧图片保持无动作。视频和音频的普通点击仍只操作播放器,三类媒体均保留右键菜单的“在画布中定位”。图片卡片本次不新增按钮语义或键盘 Tab 停靠,Enter / Space 不触发定位。 +- 影响范围:`ToolCallView`、消息气泡交互测试和画布 Agent 前端专题文档;不修改共享 DTO、后端 API、SpacetimeDB 或 viewport 动画语义。 +- 验证方式:覆盖有效图片单击、旧图片无动作、视频 / 音频单击无定位及三类媒体右键定位;运行前端定向测试、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +--- + ## 2026-07-30 画布 Agent 结果通过实例级 Action Context 刷新并从右键菜单定位资源 - 背景:画布 Agent 图片、视频和音频结果需要提供画布定位和结果完成后的工程刷新;若继续从舞台向面板、消息和工具结果逐层传 callback,会扩大现有 prop drilling,而把 callback 或瞬时命令放入全局 Zustand 又会引入多实例和卸载残留问题。直接把点击和定位语义附到生成媒体上还会让视频 / 音频的播放、暂停、拖动与音量操作误触发画布聚焦,并给原生媒体控件附加错误的定位标签。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 5c4456959..bcb5dad85 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -153,7 +153,7 @@ - Agent 面板能读取当前工程会话列表;无历史会话时发送第一条消息会先创建“新对话”。支持新建会话、切换会话和删除当前会话;删除必须通过独立确认弹窗完成,不能在面板下方追加确认内容。 - Agent 输入支持文本消息和带附件的文本消息;不支持纯附件消息。附件选择弹窗可在“画布 / 素材库”之间切换,只展示图片类资源,最多选择 9 张。 - 发送消息后,面板先展示本地用户消息和请求等待态,再应用普通 JSON 响应中的 `deltaMessages`;客户端取消等待只终止本次 transport 等待,不把已经确认入队的外部生成任务改成停止态。 -- Agent 工具任务完成并懒回填后,消息内缩略图不显示名称;前端通过编辑器作用域 Action Context 的 `refreshCanvas()` 直接重新读取工程快照和素材库,不从 Editor 经 Stage、Panel 和 MessageBubble 透传刷新 callback。图片、视频和音频结果携带有效 `resourceId` 时,在素材右键菜单显示“在画布中定位”,并通过同一 Context 的 `focusResource(resourceId)` 请求画布在 `420ms` 内平滑 fit 到对应图层;普通媒体卡片不声明按钮语义或 `tabIndex`,点击、Enter 和 Space 均不得触发定位,视频和音频的原生播放器交互保持独立。定位只改变 viewport,不选择图层、不切换工具或侧栏、不收起 Agent 面板,也不避让面板覆盖区。缺少 `resourceId` 时不显示定位菜单项,目标图层已删除时保持无动作。对话入口触发生成时不创建“即将生成”画布占位,生成完成后由后端 `canvasCompletion` 落新图层。规划或工具失败时消息内必须保留可回读的失败状态和错误气泡,不能只弹一次性 toast 或返回瞬时 `errorMessage`。 +- Agent 工具任务完成并懒回填后,消息内缩略图不显示名称;前端通过编辑器作用域 Action Context 的 `refreshCanvas()` 直接重新读取工程快照和素材库,不从 Editor 经 Stage、Panel 和 MessageBubble 透传刷新 callback。图片、视频和音频结果携带有效 `resourceId` 时,在素材右键菜单显示“在画布中定位”;有效图片结果的普通单击也直接通过同一 Context 的 `focusResource(resourceId)` 请求画布在 `420ms` 内平滑 fit 到对应图层。结果卡片不声明按钮语义或 `tabIndex`,Enter 和 Space 不得触发定位;视频和音频的普通点击及原生播放器交互保持独立。定位只改变 viewport,不选择图层、不切换工具或侧栏、不收起 Agent 面板,也不避让面板覆盖区。缺少 `resourceId` 时单击无动作且不显示定位菜单项,目标图层已删除时保持无动作。对话入口触发生成时不创建“即将生成”画布占位,生成完成后由后端 `canvasCompletion` 落新图层。规划或工具失败时消息内必须保留可回读的失败状态和错误气泡,不能只弹一次性 toast 或返回瞬时 `errorMessage`。 - 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。 - 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`;父流程解析并校验私有 OSS object key 后只调用一次唯一内部 `bgfilter-worker` 的 complex 链路,子 worker 负责签发 600 秒 URL、`N / Q` 限流和最多两次顺序 provider attempt,complex 失败不接入 fallback,成功二进制返回后仍由父流程完成最终持久化。有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index 84a5f4f89..0ea41ceb5 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -50,7 +50,7 @@ ## 生成结果落画板(对现有占位规则的例外) - 对话入口触发的生成**不创建"即将生成"画布占位**(区别于其余生成面板);生成中状态由工具消息和外部任务状态承载。 -- 生成完成后:结果图按统一 placement 避让模型(视口中心就近、避开现有图层、32px 间距)落画板为新图层,同时登记到默认项目素材库;前端轮询到任务终态或收到已经包含结果媒体的会话增量时,直接通过编辑器作用域 `ImageCanvasActionsContext.refreshCanvas()` 重新读取工程快照并刷新素材库,不经过 Stage、Agent 面板或消息组件逐层透传 callback。重新读取会话后,以回填的轻量媒体引用显示缩略图。图片、视频或音频结果携带有效 `resourceId` 时,右键素材后通过“在画布中定位”调用同一 Context 的 `focusResource(resourceId)`,请求画布视口在 `420ms` 内平滑 fit 到对应图层;普通媒体卡片不承载按钮语义、不进入键盘 Tab 顺序,普通点击以及 Enter / Space 均不触发画布聚焦,视频和音频的播放、暂停、拖动、音量等原生控制只操作播放器。定位不选中图层、不切换工具或侧栏、不收起 Agent 面板,也不为 Agent 面板预留可见区域。 +- 生成完成后:结果图按统一 placement 避让模型(视口中心就近、避开现有图层、32px 间距)落画板为新图层,同时登记到默认项目素材库;前端轮询到任务终态或收到已经包含结果媒体的会话增量时,直接通过编辑器作用域 `ImageCanvasActionsContext.refreshCanvas()` 重新读取工程快照并刷新素材库,不经过 Stage、Agent 面板或消息组件逐层透传 callback。重新读取会话后,以回填的轻量媒体引用显示缩略图。携带有效 `resourceId` 的图片结果在普通单击时直接调用同一 Context 的 `focusResource(resourceId)`;图片、视频或音频结果仍可从右键素材菜单选择“在画布中定位”。两种入口都请求画布视口在 `420ms` 内平滑 fit 到对应图层;结果卡片不进入键盘 Tab 顺序,Enter / Space 不触发画布聚焦,视频和音频的点击、播放、暂停、拖动、音量等原生交互只操作播放器。定位不选中图层、不切换工具或侧栏、不收起 Agent 面板,也不为 Agent 面板预留可见区域。 - 消息内生成结果缩略图必须携带并优先使用 `objectKey` / `assetObjectId`,前端通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签后渲染,不能把裸 `/generated-*` 私有路径直接交给 ``。 - 既有编辑器 worker 通过 `canvasCompletion` 写回工程快照;刷新后由 external generation task 状态和会话懒回填恢复结果。 - 该例外已同步登记在《生成类面板Lovart统一改造方案-2026-06-17》「画布占位落点」节。 @@ -126,7 +126,7 @@ 1. 助手文本随普通 JSON 消息响应一次性返回; 2. 消息请求等待态,以及工具任务的待确认、生成中、完成 / 失败状态; 3. 工具/模型标注行(生成时显示模型名 + 图标); -4. 消息内生成结果缩略图不显示名称;携带有效 `resourceId` 时可从素材右键菜单选择“在画布中定位”,以平滑视口动画定位对应画布图层;普通点击只保留媒体自身交互; +4. 消息内生成结果缩略图不显示名称;携带有效 `resourceId` 的图片可单击定位,图片、视频和音频都可从素材右键菜单选择“在画布中定位”,以平滑视口动画定位对应画布图层;视频和音频的普通点击只保留媒体自身交互; 5. 生成中的进行中动画; 6. 错误气泡(失败/余额不足,带原因); 7. 普通消息请求等待期间禁用发送按钮,不提供客户端停止操作;前端持续等待后端响应,超过 120 秒但 POST 仍 pending 时在思考气泡中显示“仍在处理中,请耐心等待”,最终成功或失败后自动移除,避免后端已持久化消息但前端中断请求后产生会话状态错位。 @@ -172,6 +172,6 @@ - 打开画布 Agent 后任务侧栏应关闭,再次打开任务侧栏时 Agent 面板应关闭;素材 / 图层面板与 Agent 可同时展开,互不改写开关状态。 - 发送消息时先本地追加用户消息,再应用 JSON 响应中的 `deltaMessages`;请求等待期间发送按钮保持禁用,前端不主动中断当前回合。 -- Agent 消息内生成结果缩略图不显示名称;轮询到任务终态并完成会话懒回填后,通过实例级 `refreshCanvas()` 统一刷新工程快照和素材库,禁止重新引入中间刷新 props。只允许从有效结果的素材右键菜单触发画布定位,普通点击以及视频 / 音频原生播放器操作不得触发;定位时必须保持 Agent 面板、当前图层选择、工具和侧栏不变,只平滑调整 viewport 到对应图层;旧消息缺少 `resourceId` 或目标图层已删除时不显示定位菜单项。 +- Agent 消息内生成结果缩略图不显示名称;轮询到任务终态并完成会话懒回填后,通过实例级 `refreshCanvas()` 统一刷新工程快照和素材库,禁止重新引入中间刷新 props。有效图片结果的普通单击和三类媒体的“在画布中定位”右键菜单项都可触发画布定位;视频 / 音频普通点击与原生播放器操作不得触发。定位时必须保持 Agent 面板、当前图层选择、工具和侧栏不变,只平滑调整 viewport 到对应图层;旧消息缺少 `resourceId` 时单击无动作且不显示定位菜单项,目标图层已删除时保持无动作。 - 对话内容可被用户选中复制;用户从输入框或对话内容点击回画布图层 / 生成器时,焦点应回到画布对象,Backspace / Delete 等画布快捷键继续生效。 - 对话正文右键菜单只复制当前气泡展示的完整文本,隐藏的内部 system 文本不得进入菜单;素材右键菜单优先于正文菜单,私有素材继续通过既有读取链路换签或代理下载,不复制会过期的临时链接。 diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx index 9c5371d5d..3608746d6 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx @@ -387,7 +387,7 @@ describe('MessageBubble', () => { expect(screen.getByRole('menuitem', { name: '下载图片' })).toBeTruthy(); }); - it('focuses generated image, video and audio only from their right-click menus', async () => { + it('focuses generated images on click and all generated media from their right-click menus', async () => { const { container } = renderMessage({ id: 9, role: 'system', @@ -431,7 +431,8 @@ describe('MessageBubble', () => { fireEvent.click(imageCard!); fireEvent.click(video!); fireEvent.click(audio!); - expect(focusResourceMock).not.toHaveBeenCalled(); + expect(focusResourceMock).toHaveBeenCalledTimes(1); + expect(focusResourceMock).toHaveBeenCalledWith('resource-image'); const targets = [ [imageCard, 'resource-image'], @@ -448,7 +449,7 @@ describe('MessageBubble', () => { ).toBeNull(), ); } - expect(focusResourceMock).toHaveBeenCalledTimes(3); + expect(focusResourceMock).toHaveBeenCalledTimes(4); focusResourceMock.mockReturnValueOnce({ found: false }); fireEvent.contextMenu(imageCard!, { clientX: 30, clientY: 40 }); diff --git a/src/components/image-editor/EditorAgentConversation/ToolCallView.test.tsx b/src/components/image-editor/EditorAgentConversation/ToolCallView.test.tsx index 778e8a7e1..616924aaa 100644 --- a/src/components/image-editor/EditorAgentConversation/ToolCallView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/ToolCallView.test.tsx @@ -176,7 +176,7 @@ describe('ToolCallView', () => { expect(refreshCanvasMock).toHaveBeenCalledTimes(1); }); - it('keeps media clicks passive and exposes canvas resources to the right-click menu', () => { + it('focuses image clicks only and exposes canvas resources to the right-click menu', () => { const onRightClickMenu = vi.fn(); const { container } = renderWithCanvasActions( { fireEvent.click(imageCards[0]!); fireEvent.keyDown(imageCards[0]!, { key: 'Enter' }); fireEvent.keyDown(imageCards[0]!, { key: ' ', code: 'Space' }); + fireEvent.click(imageCards[1]!); fireEvent.click(video!); fireEvent.click(audio!); - expect(focusResourceMock).not.toHaveBeenCalled(); + expect(focusResourceMock).toHaveBeenCalledTimes(1); + expect(focusResourceMock).toHaveBeenCalledWith('resource-image'); expect( screen.queryByRole('button', { name: '在画布中定位Agent生成图片-1', diff --git a/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx b/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx index 63462e073..fc86e0f9d 100644 --- a/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx +++ b/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx @@ -11,6 +11,57 @@ import { getExternalGenerationJobStatus } from '@/src/services/external-generati import type { RightClickMenuHandler } from './common.ts'; +type ToolCallDisplayStatus = 'pending' | 'completed' | 'failed' | 'cancelled'; + +function getInitialDisplayStatus( + status: EditorAgentToolCall['status'], +): ToolCallDisplayStatus { + switch (status) { + case 'completed': + case 'failed': + case 'cancelled': + return status; + case 'not_completed': + return 'pending'; + } +} + +function getStatusPresentation( + displayStatus: ToolCallDisplayStatus, + hasJobId: boolean, +) { + switch (displayStatus) { + case 'completed': + return { + label: '已完成', + icon: