diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md
index 0bde7f9b9..daa156bcf 100644
--- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md
+++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md
@@ -116,6 +116,7 @@
6. 错误气泡(失败/余额不足,带原因);
7. 普通消息请求等待期间禁用发送按钮,不提供客户端停止操作;前端持续等待后端响应,避免后端已持久化消息但前端中断请求后产生会话状态错位。
8. 桌面端右键消息正文可复制该条可见文本;右键消息附件或生成结果可下载素材,图片额外支持复制图片本体和“引用”到当前输入区。引用复用附件去重、9 张上限和发送链路;右键菜单只保留已有的 `objectKey` 与素材 `source`,引用时由输入区优先按 `objectKey`、缺失时按 `source` 从当前画布和素材库选项重新查询并构造 `EditorAgentAttachmentRef`,同时命中时画布优先。
+9. 消息右键菜单遵循 Canva 式单实例交互:任一菜单已打开时,下一次右键必须先关闭旧菜单;新落点是消息正文或素材时再在新位置打开对应菜单,新落点没有右键动作时仅收起旧菜单,不允许多个消息菜单并存。复制、引用或下载成功后自动关闭菜单;失败时保留菜单和失败状态,避免错误无提示消失。
不做(明确排除,防止后人补齐):
diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx
index 6b02603ff..6aec36f33 100644
--- a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx
+++ b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx
@@ -95,9 +95,74 @@ describe('MessageBubble', () => {
await waitFor(() =>
expect(copyTextToClipboard).toHaveBeenCalledWith('第一行\n第二行'),
);
- expect(
- await screen.findByRole('menuitem', { name: '已复制' }),
- ).toBeTruthy();
+ await waitFor(() =>
+ expect(
+ screen.queryByRole('menu', { name: '消息右键菜单' }),
+ ).toBeNull(),
+ );
+ });
+
+ it('closes the previous menu before another right click opens a new one', () => {
+ render(
+ <>
+
+
+ >,
+ );
+
+ const messages = screen.getAllByLabelText('Agent消息');
+ fireEvent.contextMenu(messages[0]!, { clientX: 30, clientY: 40 });
+ fireEvent.contextMenu(messages[1]!, { clientX: 70, clientY: 80 });
+
+ const menus = screen.getAllByRole('menu', { name: '消息右键菜单' });
+ expect(menus).toHaveLength(1);
+ expect(menus[0]?.style.left).toBe('70px');
+ expect(menus[0]?.style.top).toBe('80px');
+ });
+
+ it('closes an open menu when the next right click has no menu target', () => {
+ renderMessage({
+ id: 42,
+ role: 'assistant',
+ text: '右键后应可收起',
+ attachments: [],
+ toolCall: null,
+ createdAt: '2026-07-20T00:00:00Z',
+ });
+
+ fireEvent.contextMenu(screen.getByLabelText('Agent消息'), {
+ clientX: 30,
+ clientY: 40,
+ });
+ expect(screen.getByRole('menu', { name: '消息右键菜单' })).toBeTruthy();
+
+ fireEvent.contextMenu(document.body, { clientX: 70, clientY: 80 });
+
+ expect(screen.queryByRole('menu', { name: '消息右键菜单' })).toBeNull();
});
it('copies an attached WebP as PNG without opening the message text menu', async () => {
@@ -182,7 +247,11 @@ describe('MessageBubble', () => {
.fn()
.mockResolvedValue(new Blob(['webp'], { type: 'image/webp' })),
} as unknown as Response);
- await screen.findByRole('menuitem', { name: '已复制' });
+ await waitFor(() =>
+ expect(
+ screen.queryByRole('menu', { name: '消息素材右键菜单' }),
+ ).toBeNull(),
+ );
expect(bitmapClose).toHaveBeenCalledTimes(1);
});
@@ -219,9 +288,11 @@ describe('MessageBubble', () => {
objectKey: 'editor/reference.png',
}),
);
- expect(
- await screen.findByRole('menuitem', { name: '已引用' }),
- ).toBeTruthy();
+ await waitFor(() =>
+ expect(
+ screen.queryByRole('menu', { name: '消息素材右键菜单' }),
+ ).toBeNull(),
+ );
});
it('offers reference for generated images using their media lookup fields', async () => {
@@ -335,6 +406,9 @@ describe('MessageBubble', () => {
fireEvent.click(screen.getByRole('menuitem', { name: '下载视频' }));
await waitFor(() => expect(anchorClick).toHaveBeenCalledTimes(1));
+ expect(
+ screen.queryByRole('menu', { name: '消息素材右键菜单' }),
+ ).toBeNull();
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
expect(revokeObjectURL).toHaveBeenCalledWith('blob:agent-video');
});
diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx
index 689cf9663..0bbb3c367 100644
--- a/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx
+++ b/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx
@@ -115,17 +115,25 @@ export function MessageBubbleRightClickMenu({
onClose();
}
};
+ const handleContextMenu = (event: MouseEvent) => {
+ if (menuRef.current?.contains(event.target as Node)) {
+ event.preventDefault();
+ }
+ onClose();
+ };
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('pointerdown', handlePointerDown);
+ window.addEventListener('contextmenu', handleContextMenu, true);
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('scroll', onClose, true);
window.addEventListener('resize', onClose);
return () => {
window.removeEventListener('pointerdown', handlePointerDown);
+ window.removeEventListener('contextmenu', handleContextMenu, true);
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('scroll', onClose, true);
window.removeEventListener('resize', onClose);
diff --git a/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts b/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts
index fa6c5e84e..b69dd09de 100644
--- a/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts
+++ b/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts
@@ -197,12 +197,14 @@ export function useRightClickMenu({
: false;
setRightClickMenu((current) =>
current?.target === target && current.pendingAction === action
- ? {
- ...current,
- pendingAction: null,
- resultAction: action,
- result: succeeded ? 'success' : 'error',
- }
+ ? succeeded
+ ? null
+ : {
+ ...current,
+ pendingAction: null,
+ resultAction: action,
+ result: 'error',
+ }
: current,
);
},