Files
Genarrative/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx
T
k88936 676bd524ee
Project CI / Repository checks (push) Successful in 1m23s
Project CI / Frontend tests (push) Successful in 3m1s
Project CI / Backend tests (push) Successful in 3m51s
Project CI / Native shell tests (push) Successful in 13m32s
滑动条样式调整 (#149)
示例来自微信:
![shotmd-1786091231.jpg](/attachments/b59bae77-00ca-4669-8a54-a9786ae53c93)

before:
- firefox:
![shotmd-1786091298.jpg](/attachments/80bbd46e-551d-4b44-abe4-d3d0ecaa34dd)

- edge:
![shotmd-1786091062.jpg](/attachments/7918c523-1b5b-4439-926d-591e0029c5b9)

after:
- firefox:
![shotmd-1786110119.jpg](/attachments/31b22537-236b-4782-bc47-28ac5ecb0f9f)
![shotmd-1786110060.jpg](/attachments/b48d5343-fd07-451d-943a-55000600e248)

- edge:

![shotmd-1786110050.jpg](/attachments/cfd0d8e4-7d52-45f2-bab8-1b573b3a43b0)

- 使用 Lexical + OverlayScrollbars 实现统一可控的滑动条样式, 取代原来的textarea

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/149
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-08 15:10:16 +08:00

2013 lines
61 KiB
TypeScript

/* @vitest-environment jsdom */
import {
act,
fireEvent,
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 {
EditorAgentConversationDetail,
EditorAgentMessage,
EditorAgentMessageResponse,
} from '@/packages/shared/src/contracts';
import { setPlainTextEditorValue } from '@/src/components/common/AutoGrowTextArea.test-utils.ts';
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';
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 }) => (
<ImageCanvasActionsProvider
focusResource={focusResourceMock}
refreshCanvas={refreshCanvasMock}
>
{children}
</ImageCanvasActionsProvider>
),
});
}
const ATTACHMENT_PROMPT = '请参考附件';
vi.mock('@/src/services/image-editor/editorProjectClient.ts', async () => {
const actual = await vi.importActual<
typeof import('../../../services/image-editor/editorProjectClient.ts')
>('@/src/services/image-editor/editorProjectClient.ts');
return {
...actual,
createEditorProjectResource: createEditorProjectResourceMock,
};
});
vi.mock('@/src/services/image-editor/editorMediaAssetUploadClient.ts', () => ({
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
}));
vi.mock('@/src/components/image-editor/ImageCanvasFileModel.ts', async () => {
const actual = await vi.importActual<
typeof import('../ImageCanvasFileModel.ts')
>('@/src/components/image-editor/ImageCanvasFileModel.ts');
return {
...actual,
probeImageFileDimensions: probeImageFileDimensionsMock,
};
});
function createClient(): EditorAgentConversationClient {
return {
listConversations: vi.fn().mockResolvedValue([
{
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
updatedAt: '2026-07-03T00:00:00.000Z',
},
]),
createConversation: vi.fn().mockResolvedValue({
conversationId: 'conversation-2',
projectId: 'project-1',
title: '新对话',
messages: [],
createdAt: '2026-07-03T00:01:00.000Z',
updatedAt: '2026-07-03T00:01:00.000Z',
}),
getConversation: vi.fn().mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [
{
id: 0,
role: 'assistant',
text: '已经看到画布内容',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:00:10.000Z',
},
],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:10.000Z',
}),
deleteConversation: vi.fn().mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
}),
sendMessage: vi.fn().mockResolvedValue({
conversation: {
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
updatedAt: '2026-07-03T00:00:10.000Z',
},
deltaMessages: [
{
id: 1,
role: 'assistant',
text: '收到,我会参考这张图。',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:00:00.000Z',
},
],
errorMessage: null,
} as EditorAgentMessageResponse),
confirmToolCall: vi.fn(),
cancelToolCall: vi.fn(),
};
}
function enterAttachmentPrompt() {
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
ATTACHMENT_PROMPT,
);
}
afterEach(() => {
vi.useRealTimers();
});
function createPendingToolCallMessage(): EditorAgentMessage {
return {
id: 2,
role: 'system',
text: 'internal system prompt that must stay hidden',
attachments: [],
toolCall: {
toolName: 'edit-image',
status: 'not_completed',
args: {
object_image_id: 'source-image-1',
reference_image_ids: ['reference-image-1', 'reference-image-2'],
prompt: '把角色换成像素风',
},
displayArgs: {
stringArgs: [
{
name: 'prompt',
label: '提示词',
value: '把角色换成像素风',
},
],
imageArgs: [
{
name: 'object_image_id',
label: '目标图片',
refs: [
{
imageId: 'source-image-1',
imageSrc: 'data:image/png;base64,c291cmNl',
label: '原角色',
width: 512,
height: 512,
},
],
},
{
name: 'reference_image_ids',
label: '参考图片',
refs: [
{
imageId: 'reference-image-1',
imageSrc: 'data:image/png;base64,cmVmZXJlbmNlLTE=',
label: '像素风参考',
},
{
imageId: 'reference-image-2',
imageSrc: 'data:image/png;base64,cmVmZXJlbmNlLTI=',
label: '配色参考',
},
],
},
],
extras: { priceMudPoints: 3 },
},
images: [],
error: null,
},
createdAt: '2026-07-10T00:00:00.000Z',
};
}
describe('EditorAgentConversationPanelView', () => {
beforeEach(() => {
focusResourceMock.mockReset();
useImageCanvasContextStore.getState().setProjectId('project-1');
uploadEditorMediaAssetFileMock.mockReset();
uploadEditorMediaAssetFileMock.mockResolvedValue({
src: '/generated/pasted.png',
objectKey:
'generated-character-drafts/editor/agent-paste/image/pasted.png',
assetObjectId: 'asset-object-pasted',
legacyPublicPath: '/generated/pasted.png',
});
probeImageFileDimensionsMock.mockReset();
probeImageFileDimensionsMock.mockResolvedValue({ width: 320, height: 240 });
createEditorProjectResourceMock.mockReset();
createEditorProjectResourceMock.mockResolvedValue({
resourceId: 'resource-pasted',
imageSrc: '/generated/pasted.png',
objectKey:
'generated-character-drafts/editor/agent-paste/image/pasted.png',
label: '粘贴图片',
width: 320,
height: 240,
});
});
it('manages conversations, attachments and message sending inside the panel', async () => {
const client = createClient();
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
layers={[
{
id: 'layer-1',
resourceId: 'resource-1',
title: '一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯辰巳',
src: '/generated/role.png',
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 1,
sourceType: 'generated',
},
{
id: 'layer-local-only',
resourceId: '',
title: '未入库图层',
src: '/local-only.png',
x: 0,
y: 0,
width: 256,
height: 256,
originalWidth: 256,
originalHeight: 256,
zIndex: 2,
sourceType: 'uploaded',
},
]}
assets={[
{
id: 'asset-1',
label: '素材库角色',
src: '/assets/role.png',
width: 512,
height: 512,
folderId: 'project',
sourceKind: 'uploaded',
sourceType: 'uploaded',
persisted: true,
},
]}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
const newConversationButton = screen.getByRole('button', {
name: '新建对话',
}) as HTMLButtonElement;
expect(newConversationButton.disabled).toBe(false);
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'应保留到新会话的草稿',
);
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
const attachmentDialog = screen.getByRole('dialog', {
name: '选择图片附件',
});
expect(attachmentDialog.parentElement?.className).toContain(
'platform-theme--light',
);
expect(attachmentDialog.className).toContain('platform-remap-surface');
expect(
within(attachmentDialog).queryByRole('checkbox', {
name: '选择画布图片 未入库图层',
}),
).toBeNull();
fireEvent.click(
within(attachmentDialog).getByRole('checkbox', {
name: '选择画布图片 一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯',
}),
);
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
expect(
screen.getByText('一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯'),
).toBeTruthy();
fireEvent.click(newConversationButton);
await waitFor(() => {
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
});
await waitFor(() => {
expect(
(screen.getByLabelText('当前对话') as HTMLSelectElement).value,
).toBe('conversation-2');
expect(newConversationButton.disabled).toBe(true);
});
expect(screen.getByLabelText('发送给画布 Agent').textContent).toBe(
'应保留到新会话的草稿',
);
expect(
screen.getByText('一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯'),
).toBeTruthy();
expect(screen.getByRole('option', { name: '角色参考' })).toBeTruthy();
expect(screen.getByRole('option', { name: '新对话' })).toBeTruthy();
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'尚未发送的草稿',
);
expect(newConversationButton.disabled).toBe(true);
fireEvent.click(newConversationButton);
expect(client.createConversation).toHaveBeenCalledTimes(1);
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'参考附件做像素风',
);
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(screen.getByText('收到,我会参考这张图。')).toBeTruthy();
});
expect(client.sendMessage).toHaveBeenCalledWith(
'conversation-2',
expect.objectContaining({
text: '参考附件做像素风',
attachments: [
expect.objectContaining({
source: 'canvas_resource',
referenceId: 'resource-1',
label: '一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯',
}),
],
}),
expect.any(Object),
);
});
it('adds a generated result to the composer through the existing right click menu', async () => {
const client = createClient();
vi.mocked(client.getConversation).mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '生成结果',
messages: [
{
id: 9,
role: 'system',
text: 'internal tool result',
attachments: [],
toolCall: {
toolName: 'generate_image',
status: 'completed',
args: {},
displayArgs: {
stringArgs: [],
imageArgs: [],
extras: { priceMudPoints: 1 },
},
images: [
{
resourceId: 'resource-generated-1',
objectKey: 'editor/generated-1.png',
imageSrc: '/generated-editor-images/generated-1.png',
width: 512,
height: 512,
},
],
},
createdAt: '2026-07-20T00:00:00.000Z',
},
],
createdAt: '2026-07-20T00:00:00.000Z',
updatedAt: '2026-07-20T00:00:10.000Z',
});
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
layers={[
{
id: 'layer-generated-1',
resourceId: 'resource-generated-1',
title: 'Agent生成图片-1',
src: '/generated-editor-images/generated-1.png',
objectKey: 'editor/generated-1.png',
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 1,
sourceType: 'generated',
},
]}
/>,
);
const messageLog = await screen.findByRole('log', {
name: '画布 Agent 消息流',
});
await waitFor(() =>
expect(messageLog.querySelector('.grid.grid-cols-3 > div')).toBeTruthy(),
);
fireEvent.contextMenu(
messageLog.querySelector('.grid.grid-cols-3 > div')!,
{ clientX: 30, clientY: 40 },
);
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
expect(await screen.findByText('Agent生成图片-1')).toBeTruthy();
enterAttachmentPrompt();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(client.sendMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
text: ATTACHMENT_PROMPT,
attachments: [
expect.objectContaining({
source: 'canvas_resource',
referenceId: 'resource-generated-1',
objectKey: 'editor/generated-1.png',
imageSrc: '/generated-editor-images/generated-1.png',
}),
],
}),
expect.any(Object),
);
});
});
it('keeps new conversation disabled when the project has no conversation history', async () => {
const client = createClient();
vi.mocked(client.listConversations).mockResolvedValueOnce([]);
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
await waitFor(() => {
expect(client.listConversations).toHaveBeenCalledWith('project-1');
expect(screen.getByText('暂无消息')).toBeTruthy();
});
const newConversationButton = screen.getByRole('button', {
name: '新建对话',
}) as HTMLButtonElement;
expect(newConversationButton.disabled).toBe(true);
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'未发送内容',
);
expect(newConversationButton.disabled).toBe(true);
fireEvent.click(newConversationButton);
expect(client.createConversation).not.toHaveBeenCalled();
});
it('preserves the current conversation draft when creating a conversation fails', async () => {
const client = createClient();
vi.mocked(client.createConversation).mockRejectedValueOnce(
new Error('创建新会话失败'),
);
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'需要保留的草稿',
);
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
await waitFor(() => {
expect(screen.getByText('创建新会话失败')).toBeTruthy();
});
expect(screen.getByLabelText('发送给画布 Agent').textContent).toBe(
'需要保留的草稿',
);
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
expect((screen.getByLabelText('当前对话') as HTMLSelectElement).value).toBe(
'conversation-1',
);
});
it('preserves newer draft edits while creating a conversation', async () => {
const client = createClient();
let resolveCreate!: (detail: EditorAgentConversationDetail) => void;
vi.mocked(client.createConversation).mockImplementationOnce(
() =>
new Promise<EditorAgentConversationDetail>((resolve) => {
resolveCreate = resolve;
}),
);
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
const draftInput = screen.getByLabelText(
'发送给画布 Agent',
) as HTMLElement;
setPlainTextEditorValue(draftInput, '旧项目草稿');
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
await waitFor(() => {
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
expect(draftInput.getAttribute('aria-disabled')).toBeNull();
});
const conversationSelect = screen.getByLabelText(
'当前对话',
) as HTMLSelectElement;
const sendButton = screen.getByRole('button', {
name: '发送',
}) as HTMLButtonElement;
expect(conversationSelect.disabled).toBe(true);
expect(sendButton.disabled).toBe(true);
expect(
(screen.getByRole('button', { name: '添加附件' }) as HTMLButtonElement)
.disabled,
).toBe(false);
setPlainTextEditorValue(draftInput, '创建期间更新的草稿');
fireEvent.click(sendButton);
expect(client.sendMessage).not.toHaveBeenCalled();
expect(draftInput.textContent).toBe('创建期间更新的草稿');
await act(async () => {
resolveCreate({
conversationId: 'conversation-2',
projectId: 'project-1',
title: '新对话',
messages: [],
createdAt: '2026-07-03T00:01:00.000Z',
updatedAt: '2026-07-03T00:01:00.000Z',
});
await Promise.resolve();
});
expect(draftInput.textContent).toBe('创建期间更新的草稿');
expect(conversationSelect.value).toBe('conversation-2');
expect(conversationSelect.disabled).toBe(false);
expect(sendButton.disabled).toBe(false);
});
it('preserves the draft when submission is attempted during conversation loading', async () => {
const client = createClient();
let resolveConversationLoad!: (
detail: EditorAgentConversationDetail,
) => void;
vi.mocked(client.listConversations).mockResolvedValueOnce([
{
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
updatedAt: '2026-07-03T00:01:00.000Z',
},
{
conversationId: 'conversation-2',
projectId: 'project-1',
title: '背景参考',
updatedAt: '2026-07-03T00:00:00.000Z',
},
]);
vi.mocked(client.getConversation)
.mockResolvedValueOnce({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [
{
id: 0,
role: 'assistant',
text: '已经看到画布内容',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:00:10.000Z',
},
],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:10.000Z',
})
.mockImplementationOnce(
() =>
new Promise<EditorAgentConversationDetail>((resolve) => {
resolveConversationLoad = resolve;
}),
);
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
const draftInput = screen.getByLabelText(
'发送给画布 Agent',
) as HTMLElement;
const conversationSelect = screen.getByLabelText(
'当前对话',
) as HTMLSelectElement;
setPlainTextEditorValue(draftInput, '切换期间必须保留的草稿');
fireEvent.change(conversationSelect, {
target: { value: 'conversation-2' },
});
const sendButton = screen.getByRole('button', {
name: '发送',
}) as HTMLButtonElement;
await waitFor(() => {
expect(client.getConversation).toHaveBeenCalledWith('conversation-2');
expect(sendButton.disabled).toBe(true);
});
fireEvent.click(sendButton);
expect(client.sendMessage).not.toHaveBeenCalled();
expect(draftInput.textContent).toBe('切换期间必须保留的草稿');
await act(async () => {
resolveConversationLoad({
conversationId: 'conversation-2',
projectId: 'project-1',
title: '背景参考',
messages: [
{
id: 1,
role: 'assistant',
text: '背景会话内容',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:02:00.000Z',
},
],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:02:00.000Z',
});
await Promise.resolve();
});
expect(await screen.findByText('背景会话内容')).toBeTruthy();
expect(draftInput.textContent).toBe('切换期间必须保留的草稿');
expect(sendButton.disabled).toBe(false);
});
it('disables sending while a message request is pending without showing stop', async () => {
const client = createClient();
let resolveSend!: (response: EditorAgentMessageResponse) => void;
vi.mocked(client.sendMessage).mockImplementation(
() =>
new Promise<EditorAgentMessageResponse>((resolve) => {
resolveSend = resolve;
}),
);
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
vi.useFakeTimers();
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'继续规划',
);
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await act(async () => {
await Promise.resolve();
});
expect(
screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'),
).toBe(true);
expect(screen.queryByRole('button', { name: '停止' })).toBeNull();
expect(screen.queryByText('仍在处理中,请耐心等待')).toBeNull();
act(() => {
vi.advanceTimersByTime(EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS);
});
expect(screen.getByText('仍在处理中,请耐心等待')).toBeTruthy();
await act(async () => {
resolveSend({
conversation: {
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
updatedAt: '2026-07-03T00:00:20.000Z',
},
deltaMessages: [],
errorMessage: null,
});
await Promise.resolve();
});
expect(screen.queryByText('仍在处理中,请耐心等待')).toBeNull();
});
it('uploads pasted images as canvas attachments before sending', async () => {
const client = createClient();
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
const pastedImage = new File(['pasted-image'], 'pasted.png', {
type: 'image/png',
});
const input = screen.getByLabelText('发送给画布 Agent');
fireEvent.paste(input, {
clipboardData: {
files: [pastedImage],
},
});
await waitFor(() => {
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
pastedImage,
'image',
expect.objectContaining({
entityId: 'project-1',
metadata: { source: 'agent-input-paste' },
}),
);
});
expect(probeImageFileDimensionsMock).toHaveBeenCalledWith(pastedImage);
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'project-1',
expect.objectContaining({
imageSrc: '/generated/pasted.png',
objectKey:
'generated-character-drafts/editor/agent-paste/image/pasted.png',
assetObjectId: 'asset-object-pasted',
width: 320,
height: 240,
sourceType: 'uploaded',
}),
);
await waitFor(() => {
expect(screen.getByText('粘贴图片')).toBeTruthy();
});
enterAttachmentPrompt();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(client.sendMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
text: ATTACHMENT_PROMPT,
attachments: [
expect.objectContaining({
source: 'canvas_resource',
referenceId: 'resource-pasted',
imageSrc: '/generated/pasted.png',
width: 320,
height: 240,
}),
],
}),
expect.any(Object),
);
});
expect(screen.getByRole('option', { name: '角色参考' })).toBeTruthy();
expect(screen.queryByRole('option', { name: 'conversation-1' })).toBeNull();
});
it('normalizes a referenced historical attachment outside the current canvas', async () => {
const client = createClient();
vi.mocked(client.getConversation).mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '历史附件',
messages: [
{
id: 8,
role: 'user',
text: '参考这张粘贴图',
attachments: [
{
source: 'canvas_resource',
referenceId: 'resource-historical-paste',
objectKey: 'editor/agent-paste/historical.png',
imageSrc: '/generated/historical-paste.png',
thumbnailSrc: null,
label: ' 历史(粘贴):图 ',
width: 320,
height: 240,
},
],
toolCall: null,
createdAt: '2026-07-20T00:00:00.000Z',
},
],
createdAt: '2026-07-20T00:00:00.000Z',
updatedAt: '2026-07-20T00:00:10.000Z',
});
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
layers={[]}
assets={[]}
/>,
);
const historicalAttachment = await screen.findByText('历史(粘贴):图');
fireEvent.contextMenu(historicalAttachment.closest('.group')!);
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
await waitFor(() => {
expect(screen.getByText('历史粘贴图')).toBeTruthy();
});
enterAttachmentPrompt();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(client.sendMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
attachments: [
{
source: 'canvas_resource',
referenceId: 'resource-historical-paste',
objectKey: 'editor/agent-paste/historical.png',
imageSrc: '/generated/historical-paste.png',
thumbnailSrc: null,
label: '历史粘贴图',
width: 320,
height: 240,
},
],
}),
expect.any(Object),
);
});
expect(screen.getByText('历史(粘贴):图')).toBeTruthy();
expect(screen.getByText('历史粘贴图')).toBeTruthy();
});
it('refreshes an attachment matched by source and reference id when sending', async () => {
const client = createClient();
vi.mocked(client.getConversation).mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '附件更新',
messages: [
{
id: 8,
role: 'user',
text: '参考旧版图片',
attachments: [
{
source: 'canvas_resource',
referenceId: 'resource-updated',
objectKey: 'editor/old.png',
imageSrc: '/generated/old.png',
label: '旧版附件',
},
],
toolCall: null,
createdAt: '2026-07-20T00:00:00.000Z',
},
],
createdAt: '2026-07-20T00:00:00.000Z',
updatedAt: '2026-07-20T00:00:10.000Z',
});
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
layers={[
{
id: 'layer-updated',
resourceId: 'resource-updated',
title: '最新附件',
src: '/generated/current.png',
objectKey: 'editor/current.png',
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 1,
sourceType: 'generated',
},
]}
/>,
);
const historicalAttachment = await screen.findByText('旧版附件');
fireEvent.contextMenu(historicalAttachment.closest('.group')!);
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
await waitFor(() => {
expect(screen.getAllByText('旧版附件')).toHaveLength(2);
});
expect(screen.queryByText('最新附件')).toBeNull();
enterAttachmentPrompt();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(client.sendMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
attachments: [
expect.objectContaining({
source: 'canvas_resource',
referenceId: 'resource-updated',
objectKey: 'editor/current.png',
imageSrc: '/generated/current.png',
}),
],
}),
expect.any(Object),
);
});
});
it('keeps a referenced image when a pending paste upload finishes', async () => {
const client = createClient();
vi.mocked(client.getConversation).mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '并发附件',
messages: [
{
id: 9,
role: 'system',
text: 'internal tool result',
attachments: [],
toolCall: {
toolName: 'generate_image',
status: 'completed',
args: {},
displayArgs: {
stringArgs: [],
imageArgs: [],
extras: { priceMudPoints: 1 },
},
images: [
{
resourceId: 'resource-referenced',
objectKey: 'editor/referenced.png',
imageSrc: '/generated-editor-images/referenced.png',
width: 512,
height: 512,
},
],
},
createdAt: '2026-07-20T00:00:00.000Z',
},
],
createdAt: '2026-07-20T00:00:00.000Z',
updatedAt: '2026-07-20T00:00:10.000Z',
});
let resolveUpload!: (upload: {
src: string;
objectKey: string;
assetObjectId: string;
legacyPublicPath: string;
}) => void;
uploadEditorMediaAssetFileMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveUpload = resolve;
}),
);
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
layers={[
{
id: 'layer-referenced',
resourceId: 'resource-referenced',
title: '并发引用图',
src: '/generated-editor-images/referenced.png',
objectKey: 'editor/referenced.png',
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 1,
sourceType: 'generated',
},
]}
/>,
);
const messageLog = await screen.findByRole('log', {
name: '画布 Agent 消息流',
});
await waitFor(() =>
expect(messageLog.querySelector('.grid.grid-cols-3 > div')).toBeTruthy(),
);
const input = screen.getByLabelText('发送给画布 Agent');
fireEvent.paste(input, {
clipboardData: {
files: [
new File(['pending-paste'], 'pending.png', { type: 'image/png' }),
],
},
});
await waitFor(() =>
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(1),
);
fireEvent.contextMenu(
messageLog.querySelector('.grid.grid-cols-3 > div')!,
{ clientX: 30, clientY: 40 },
);
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
expect(await screen.findByText('并发引用图')).toBeTruthy();
await act(async () => {
resolveUpload({
src: '/generated/pasted.png',
objectKey:
'generated-character-drafts/editor/agent-paste/image/pasted.png',
assetObjectId: 'asset-object-pasted',
legacyPublicPath: '/generated/pasted.png',
});
});
expect(await screen.findByText('粘贴图片')).toBeTruthy();
enterAttachmentPrompt();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(client.sendMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
attachments: [
expect.objectContaining({
referenceId: 'resource-referenced',
}),
expect.objectContaining({ referenceId: 'resource-pasted' }),
],
}),
expect.any(Object),
);
});
});
it('evaluates the nine-image limit against references added during paste', async () => {
const client = createClient();
const layers = Array.from({ length: 9 }, (_, index) => ({
id: `layer-${index + 1}`,
resourceId: `resource-${index + 1}`,
title: `附件-${index + 1}`,
src: `/generated/attachment-${index + 1}.png`,
objectKey: `editor/attachment-${index + 1}.png`,
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: index + 1,
sourceType: 'generated' as const,
}));
vi.mocked(client.getConversation).mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '附件上限',
messages: [
{
id: 9,
role: 'system',
text: 'internal tool result',
attachments: [],
toolCall: {
toolName: 'generate_image',
status: 'completed',
args: {},
displayArgs: {
stringArgs: [],
imageArgs: [],
extras: { priceMudPoints: 1 },
},
images: [
{
resourceId: 'resource-9',
objectKey: 'editor/attachment-9.png',
imageSrc: '/generated/attachment-9.png',
width: 512,
height: 512,
},
],
},
createdAt: '2026-07-20T00:00:00.000Z',
},
],
createdAt: '2026-07-20T00:00:00.000Z',
updatedAt: '2026-07-20T00:00:10.000Z',
});
let resolveUpload!: (upload: {
src: string;
objectKey: string;
assetObjectId: string;
legacyPublicPath: string;
}) => void;
uploadEditorMediaAssetFileMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveUpload = resolve;
}),
);
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
layers={layers}
/>,
);
const messageLog = await screen.findByRole('log', {
name: '画布 Agent 消息流',
});
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
const attachmentDialog = screen.getByRole('dialog', {
name: '选择图片附件',
});
for (let index = 1; index <= 8; index += 1) {
fireEvent.click(
within(attachmentDialog).getByRole('checkbox', {
name: `选择画布图片 附件-${index}`,
}),
);
}
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
const input = screen.getByLabelText('发送给画布 Agent');
fireEvent.paste(input, {
clipboardData: {
files: [
new File(['pending-paste'], 'pending.png', { type: 'image/png' }),
],
},
});
await waitFor(() =>
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(1),
);
await waitFor(() =>
expect(messageLog.querySelector('.grid.grid-cols-3 > div')).toBeTruthy(),
);
fireEvent.contextMenu(
messageLog.querySelector('.grid.grid-cols-3 > div')!,
{ clientX: 30, clientY: 40 },
);
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
expect(await screen.findByText('附件-9')).toBeTruthy();
await act(async () => {
resolveUpload({
src: '/generated/pasted.png',
objectKey:
'generated-character-drafts/editor/agent-paste/image/pasted.png',
assetObjectId: 'asset-object-pasted',
legacyPublicPath: '/generated/pasted.png',
});
});
expect(await screen.findByText('最多 9 张')).toBeTruthy();
expect(screen.queryByText('粘贴图片')).toBeNull();
enterAttachmentPrompt();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
const request = vi.mocked(client.sendMessage).mock.calls[0]?.[1];
expect(request?.attachments).toHaveLength(9);
expect(request?.attachments).toEqual(
expect.arrayContaining([
expect.objectContaining({ referenceId: 'resource-9' }),
]),
);
expect(request?.attachments).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ referenceId: 'resource-pasted' }),
]),
);
});
});
it('rejects selected attachments when the text input is empty', async () => {
const client = createClient();
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
layers={[
{
id: 'layer-1',
resourceId: 'resource-1',
title: '角色图层',
src: '/generated/role.png',
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 1,
sourceType: 'generated',
},
]}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
const attachmentDialog = screen.getByRole('dialog', {
name: '选择图片附件',
});
fireEvent.click(
within(attachmentDialog).getByRole('checkbox', {
name: '选择画布图片 角色图层',
}),
);
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
const sendButton = screen.getByRole('button', {
name: '发送',
}) as HTMLButtonElement;
expect(sendButton.disabled).toBe(true);
fireEvent.click(sendButton);
expect(client.sendMessage).not.toHaveBeenCalled();
expect(screen.getByText('角色图层')).toBeTruthy();
});
it('restores the draft and selected attachments when sending fails', async () => {
const client = createClient();
vi.mocked(client.sendMessage).mockRejectedValueOnce(
new Error('Network error'),
);
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
layers={[
{
id: 'layer-1',
resourceId: 'resource-1',
title: '角色图层',
src: '/generated/role.png',
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 1,
sourceType: 'generated',
},
]}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
const attachmentDialog = screen.getByRole('dialog', {
name: '选择图片附件',
});
fireEvent.click(
within(attachmentDialog).getByRole('checkbox', {
name: '选择画布图片 角色图层',
}),
);
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'失败后恢复这条草稿',
);
fireEvent.click(screen.getByRole('button', { name: '发送' }));
expect(await screen.findByText('Network error')).toBeTruthy();
await waitFor(() =>
expect(screen.getByLabelText('发送给画布 Agent').textContent).toBe(
'失败后恢复这条草稿',
),
);
expect(screen.getByText('角色图层')).toBeTruthy();
expect(
within(
screen.getByRole('log', { name: '画布 Agent 消息流' }),
).queryByText('失败后恢复这条草稿'),
).toBeNull();
});
it('merges sent attachments with references added while a failed request is pending', async () => {
const client = createClient();
let rejectSend!: (reason?: unknown) => void;
vi.mocked(client.sendMessage).mockImplementationOnce(
() =>
new Promise<EditorAgentMessageResponse>((_resolve, reject) => {
rejectSend = reject;
}),
);
vi.mocked(client.getConversation).mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '失败恢复并发附件',
messages: [
{
id: 9,
role: 'system',
text: 'internal tool result',
attachments: [],
toolCall: {
toolName: 'generate_image',
status: 'completed',
args: {},
displayArgs: {
stringArgs: [],
imageArgs: [],
extras: { priceMudPoints: 1 },
},
images: [
{
resourceId: 'resource-b',
objectKey: 'editor/attachment-b.png',
imageSrc: '/generated-editor-images/attachment-b.png',
width: 512,
height: 512,
},
],
},
createdAt: '2026-07-20T00:00:00.000Z',
},
],
createdAt: '2026-07-20T00:00:00.000Z',
updatedAt: '2026-07-20T00:00:10.000Z',
});
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
layers={[
{
id: 'layer-a',
resourceId: 'resource-a',
title: '附件 A',
src: '/generated-editor-images/attachment-a.png',
objectKey: 'editor/attachment-a.png',
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 1,
sourceType: 'generated',
},
{
id: 'layer-b',
resourceId: 'resource-b',
title: '附件 B',
src: '/generated-editor-images/attachment-b.png',
objectKey: 'editor/attachment-b.png',
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 2,
sourceType: 'generated',
},
]}
/>,
);
const messageLog = await screen.findByRole('log', {
name: '画布 Agent 消息流',
});
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
const attachmentDialog = screen.getByRole('dialog', {
name: '选择图片附件',
});
fireEvent.click(
within(attachmentDialog).getByRole('checkbox', {
name: '选择画布图片 附件 A',
}),
);
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
enterAttachmentPrompt();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(client.sendMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
attachments: [expect.objectContaining({ referenceId: 'resource-a' })],
}),
expect.any(Object),
);
});
await waitFor(() =>
expect(messageLog.querySelector('.grid.grid-cols-3 > div')).toBeTruthy(),
);
fireEvent.contextMenu(
messageLog.querySelector('.grid.grid-cols-3 > div')!,
{ clientX: 30, clientY: 40 },
);
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
expect(await screen.findByText('附件 B')).toBeTruthy();
await act(async () => {
rejectSend(new Error('Network error'));
});
expect(await screen.findByText('Network error')).toBeTruthy();
expect(screen.getByText('附件 A')).toBeTruthy();
expect(screen.getByText('附件 B')).toBeTruthy();
});
it('keeps the current snapshot when failed recovery has the same attachment identity', async () => {
const client = createClient();
let rejectSend!: (reason?: unknown) => void;
vi.mocked(client.sendMessage).mockImplementationOnce(
() =>
new Promise<EditorAgentMessageResponse>((_resolve, reject) => {
rejectSend = reject;
}),
);
const oldLayer = {
id: 'layer-updated',
resourceId: 'resource-updated',
title: '旧版附件',
src: '/generated-editor-images/old.png',
objectKey: 'editor/old.png',
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 1,
sourceType: 'generated' as const,
};
const currentLayer = {
...oldLayer,
title: '当前附件',
src: '/generated-editor-images/current.png',
objectKey: 'editor/current.png',
};
const { rerender } = render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
layers={[oldLayer]}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
let attachmentDialog = screen.getByRole('dialog', {
name: '选择图片附件',
});
fireEvent.click(
within(attachmentDialog).getByRole('checkbox', {
name: '选择画布图片 旧版附件',
}),
);
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
enterAttachmentPrompt();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(client.sendMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
attachments: [
expect.objectContaining({
referenceId: 'resource-updated',
objectKey: 'editor/old.png',
}),
],
}),
expect.any(Object),
);
});
rerender(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
layers={[currentLayer]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
attachmentDialog = screen.getByRole('dialog', {
name: '选择图片附件',
});
fireEvent.click(
within(attachmentDialog).getByRole('checkbox', {
name: '选择画布图片 当前附件',
}),
);
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
await act(async () => {
rejectSend(new Error('Network error'));
});
expect(await screen.findByText('Network error')).toBeTruthy();
expect(screen.getByText('当前附件')).toBeTruthy();
expect(screen.queryByText('旧版附件')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(client.sendMessage).toHaveBeenCalledTimes(2);
expect(
vi.mocked(client.sendMessage).mock.calls[1]?.[1].attachments,
).toEqual([
expect.objectContaining({
referenceId: 'resource-updated',
objectKey: 'editor/current.png',
}),
]);
});
});
it('keeps the latest nine attachments when failed attachment recovery would exceed the limit', async () => {
const client = createClient();
let rejectSend!: (reason?: unknown) => void;
vi.mocked(client.sendMessage).mockImplementationOnce(
() =>
new Promise<EditorAgentMessageResponse>((_resolve, reject) => {
rejectSend = reject;
}),
);
const layers = Array.from({ length: 18 }, (_, index) => ({
id: `layer-${index + 1}`,
resourceId: `resource-${index + 1}`,
title: `附件-${index + 1}`,
src: `/generated-editor-images/attachment-${index + 1}.png`,
objectKey: `editor/attachment-${index + 1}.png`,
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: index + 1,
sourceType: 'generated' as const,
}));
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
layers={layers}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
let attachmentDialog = screen.getByRole('dialog', {
name: '选择图片附件',
});
for (let index = 1; index <= 9; index += 1) {
fireEvent.click(
within(attachmentDialog).getByRole('checkbox', {
name: `选择画布图片 附件-${index}`,
}),
);
}
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
enterAttachmentPrompt();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(client.sendMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({ attachments: expect.any(Array) }),
expect.any(Object),
);
expect(
vi.mocked(client.sendMessage).mock.calls[0]?.[1].attachments,
).toHaveLength(9);
});
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
attachmentDialog = screen.getByRole('dialog', {
name: '选择图片附件',
});
for (let index = 10; index <= 18; index += 1) {
fireEvent.click(
within(attachmentDialog).getByRole('checkbox', {
name: `选择画布图片 附件-${index}`,
}),
);
}
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
await act(async () => {
rejectSend(new Error('Network error'));
});
expect(
await screen.findByText('最多 9 张,发送失败的附件未恢复'),
).toBeTruthy();
for (let index = 1; index <= 9; index += 1) {
expect(screen.queryByText(`附件-${index}`)).toBeNull();
}
for (let index = 10; index <= 18; index += 1) {
expect(screen.getByText(`附件-${index}`)).toBeTruthy();
}
});
it('preserves messages when the panel is collapsed and reopened', async () => {
const client = createClient();
vi.mocked(client.sendMessage).mockResolvedValue({
conversation: {
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
updatedAt: '2026-07-03T00:00:10.000Z',
},
deltaMessages: [
{
id: 1,
role: 'assistant',
text: '我来生成图片。',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:00:00.000Z',
},
],
errorMessage: null,
} as EditorAgentMessageResponse);
const { rerender } = render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'生成一张图片',
);
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(screen.getByText('我来生成图片。')).toBeTruthy();
});
rerender(
<EditorAgentConversationPanelView
open={false}
onToggleOpen={vi.fn()}
client={client}
/>,
);
expect(screen.getByRole('button', { name: '打开画布 Agent' })).toBeTruthy();
rerender(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
expect(screen.getByText('我来生成图片。')).toBeTruthy();
});
it('shows structured pending details, hides system text and confirms once', async () => {
const client = createClient();
const pendingMessage = createPendingToolCallMessage();
const confirmedMessage: EditorAgentMessage = {
...pendingMessage,
text: 'internal completed tool output that must stay hidden',
toolCall: {
...pendingMessage.toolCall!,
externalJobId: 'task-edit-panel-1',
},
};
vi.mocked(client.getConversation)
.mockResolvedValueOnce({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [pendingMessage],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:10.000Z',
})
.mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [confirmedMessage],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:10.000Z',
});
let resolveConfirmation: () => void = () => {};
vi.mocked(client.confirmToolCall).mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveConfirmation = resolve;
}),
);
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
expect(await screen.findByText('把角色换成像素风')).toBeTruthy();
expect(screen.getByText('提示词')).toBeTruthy();
expect(screen.getByText('2 张')).toBeTruthy();
expect(screen.getByAltText('目标图片:原角色')).toBeTruthy();
expect(screen.getByAltText('参考图片:像素风参考')).toBeTruthy();
expect(screen.getByAltText('参考图片:配色参考')).toBeTruthy();
expect(screen.getByText('预计消耗 3泥点')).toBeTruthy();
expect(screen.queryByText('source-image-1')).toBeNull();
expect(screen.queryByText('reference-image-1')).toBeNull();
expect(
screen.queryByText('internal system prompt that must stay hidden'),
).toBeNull();
const getConversationCallsBeforeConfirm = vi.mocked(client.getConversation)
.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(client.confirmToolCall).toHaveBeenCalledWith('conversation-1', 2);
});
expect(screen.getByRole('button', { name: '执行中' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '确认' })).toBeNull();
expect(
screen.getByRole('button', { name: '取消' }).hasAttribute('disabled'),
).toBe(true);
await act(async () => {
resolveConfirmation();
});
expect(await screen.findByText('执行中')).toBeTruthy();
expect(client.getConversation).toHaveBeenCalledTimes(
getConversationCallsBeforeConfirm + 1,
);
expect(
screen.queryByText(
'internal completed tool output that must stay hidden',
),
).toBeNull();
expect(refreshCanvasMock).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: '确认' })).toBeNull();
});
it('cancels a pending tool call without exposing its system text', async () => {
const client = createClient();
const pendingMessage = createPendingToolCallMessage();
const cancelledMessage: EditorAgentMessage = {
...pendingMessage,
text: 'internal cancelled tool output that must stay hidden',
toolCall: {
...pendingMessage.toolCall!,
status: 'cancelled',
},
};
vi.mocked(client.getConversation)
.mockResolvedValueOnce({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [pendingMessage],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:10.000Z',
})
.mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [cancelledMessage],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:10.000Z',
});
vi.mocked(client.cancelToolCall).mockResolvedValue(undefined);
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
const cancelButton = await screen.findByRole('button', { name: '取消' });
const getConversationCallsBeforeCancel = vi.mocked(client.getConversation)
.mock.calls.length;
fireEvent.click(cancelButton);
expect(await screen.findByText('已取消')).toBeTruthy();
expect(client.cancelToolCall).toHaveBeenCalledWith('conversation-1', 2);
expect(client.getConversation).toHaveBeenCalledTimes(
getConversationCallsBeforeCancel + 1,
);
expect(
screen.queryByText(
'internal cancelled tool output that must stay hidden',
),
).toBeNull();
expect(screen.queryByRole('button', { name: '确认' })).toBeNull();
});
it('keeps wheel scrolling inside the message history and input', async () => {
const client = createClient();
const parentWheel = vi.fn();
render(
<div onWheel={parentWheel}>
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
/>
</div>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
const messageWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 120,
});
fireEvent(
screen.getByRole('log', { name: '画布 Agent 消息流' }),
messageWheel,
);
expect(parentWheel).not.toHaveBeenCalled();
expect(messageWheel.defaultPrevented).toBe(false);
const inputWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 120,
});
fireEvent(screen.getByLabelText('发送给画布 Agent'), inputWheel);
expect(parentWheel).not.toHaveBeenCalled();
expect(inputWheel.defaultPrevented).toBe(false);
});
it('confirms conversation deletion from an independent dialog', async () => {
const client = createClient();
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: '删除当前对话' }));
const confirmDialog = screen.getByRole('dialog', { name: '删除对话' });
expect(confirmDialog.parentElement?.className).toContain(
'platform-theme--light',
);
expect(confirmDialog.parentElement?.className).toContain('z-[140]');
expect(confirmDialog.className).toContain('platform-remap-surface');
expect(
within(confirmDialog).getByText('确认删除这个对话吗?'),
).toBeTruthy();
fireEvent.click(
within(confirmDialog).getByRole('button', { name: '确认删除' }),
);
await waitFor(() => {
expect(client.deleteConversation).toHaveBeenCalledWith('conversation-1');
});
});
});