fix interactive error

This commit is contained in:
2026-07-20 19:52:49 +08:00
parent e6fd4a1fe1
commit e8d8dee767
4 changed files with 98 additions and 13 deletions
@@ -116,6 +116,7 @@
6. 错误气泡(失败/余额不足,带原因);
7. 普通消息请求等待期间禁用发送按钮,不提供客户端停止操作;前端持续等待后端响应,避免后端已持久化消息但前端中断请求后产生会话状态错位。
8. 桌面端右键消息正文可复制该条可见文本;右键消息附件或生成结果可下载素材,图片额外支持复制图片本体和“引用”到当前输入区。引用复用附件去重、9 张上限和发送链路;右键菜单只保留已有的 `objectKey` 与素材 `source`,引用时由输入区优先按 `objectKey`、缺失时按 `source` 从当前画布和素材库选项重新查询并构造 `EditorAgentAttachmentRef`,同时命中时画布优先。
9. 消息右键菜单遵循 Canva 式单实例交互:任一菜单已打开时,下一次右键必须先关闭旧菜单;新落点是消息正文或素材时再在新位置打开对应菜单,新落点没有右键动作时仅收起旧菜单,不允许多个消息菜单并存。复制、引用或下载成功后自动关闭菜单;失败时保留菜单和失败状态,避免错误无提示消失。
不做(明确排除,防止后人补齐):
@@ -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(
<>
<MessageBubble
message={{
id: 40,
role: 'assistant',
text: '第一条消息',
attachments: [],
toolCall: null,
createdAt: '2026-07-20T00:00:00Z',
}}
busyAction={null}
onConfirmToolCall={vi.fn()}
onCancelToolCall={vi.fn()}
/>
<MessageBubble
message={{
id: 41,
role: 'assistant',
text: '第二条消息',
attachments: [],
toolCall: null,
createdAt: '2026-07-20T00:00:00Z',
}}
busyAction={null}
onConfirmToolCall={vi.fn()}
onCancelToolCall={vi.fn()}
/>
</>,
);
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');
});
@@ -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);
@@ -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,
);
},