feat(agents): move conversation attachments into a header side panel

This commit is contained in:
鲨鱼辣椒 committed 2026-10-12 23:12:24 +08:00
1 parent e9f204776a
commit 0d54690dd6
10 files changed
+589 -98

No files matched your search

+55 -26
View File
@@ -61,6 +61,16 @@ beforeEach(() => {
});
afterEach(cleanup);
async function chooseFileFromMenu(name = '添加文件或图片') {
fireEvent.keyDown(screen.getByRole('button', { name: '添加文件或图片' }), { key: 'ArrowDown' });
fireEvent.click(await screen.findByRole('menuitem', { name, exact: true }));
}
async function openLibrary(count: number) {
fireEvent.click(await screen.findByRole('button', { name: `附件,共 ${count} 个文件` }));
return screen.findByRole('dialog', { name: '本次对话的附件' });
}
describe('CloudComposerAttachments', () => {
it.each([
{ file: photo, type: 'image/png', kind: 'image', label: '图片文件' },
@@ -70,7 +80,7 @@ describe('CloudComposerAttachments', () => {
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation, input) => operation === 'confirmAttachment' ? { attachments: [file] } : base(operation, input));
const view = open({ initialSelected: ['existing-id'] });
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
await chooseFileFromMenu();
const chip = await screen.findByRole('button', { name: '移除附件 ' + file.file_name });
expect(chip.closest('.agent-attachment-card')).toHaveAttribute('data-kind', kind);
expect(within(chip.closest('.agent-attachment-card') as HTMLElement).getByLabelText(label)).toBeInTheDocument();
@@ -90,7 +100,7 @@ describe('CloudComposerAttachments', () => {
it('does not create a conversation or change existing input when the native picker is canceled', async () => {
api.upload.mockResolvedValue(null);
const view = open({ initialSelected: ['existing-id'] });
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
await chooseFileFromMenu();
await waitFor(() => expect(view.onBusy).toHaveBeenLastCalledWith(false));
expect(view.ensureThread).not.toHaveBeenCalled();
expect(operationCalls('confirmAttachment')).toHaveLength(0);
@@ -107,7 +117,7 @@ describe('CloudComposerAttachments', () => {
return base(operation, input);
});
const view = open({ initialSelected: ['existing-id'] });
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
await chooseFileFromMenu();
expect(await screen.findByRole('alert')).toHaveTextContent('确认时连接中断');
expect(view.hasPending()).toBe(true);
expect(view.onBusy).toHaveBeenLastCalledWith(false);
@@ -129,7 +139,7 @@ describe('CloudComposerAttachments', () => {
it('keeps the picked object when ensuring the conversation fails and cancels without deleting files', async () => {
const ensureThread = vi.fn().mockRejectedValueOnce(new Error('对话暂时无法创建')).mockResolvedValue('same-client-thread');
const view = open({ ensureThread, initialSelected: ['existing-id'] });
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
await chooseFileFromMenu();
await screen.findByText('对话暂时无法创建');
expect(view.hasPending()).toBe(true);
fireEvent.click(screen.getByRole('button', { name: '重试添加' }));
@@ -140,7 +150,7 @@ describe('CloudComposerAttachments', () => {
if (operation === 'confirmAttachment') throw new Error('第二个文件未确认');
return operation === 'attachments' ? { attachments: [photo] } : { files: [] };
});
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
await chooseFileFromMenu();
await screen.findByText('第二个文件未确认');
fireEvent.click(screen.getByRole('button', { name: '取消添加' }));
expect(screen.queryByRole('button', { name: '重试添加' })).not.toBeInTheDocument();
@@ -155,12 +165,13 @@ describe('CloudComposerAttachments', () => {
fireEvent.click(await screen.findByRole('button', { name: '移除附件 ' + photo.file_name }));
expect(view.onSelected).toHaveBeenLastCalledWith([]);
expect(operationCalls('deleteAttachment')).toHaveLength(0);
fireEvent.click(screen.getByRole('button', { name: '对话文件' }));
await openLibrary(2);
const choice = await screen.findByRole('checkbox', { name: '选择附件 ' + photo.file_name });
expect(choice).not.toBeChecked();
fireEvent.click(choice);
expect(view.onSelected).toHaveBeenLastCalledWith([photo.file_id]);
expect(screen.getByRole('button', { name: '移除附件 ' + photo.file_name })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '返回聊天 · 已选 1 个附件' }));
expect(await screen.findByRole('button', { name: '移除附件 ' + photo.file_name })).toBeVisible();
expect(api.upload).not.toHaveBeenCalled();
expect(view.ensureThread).not.toHaveBeenCalled();
});
@@ -173,7 +184,9 @@ describe('CloudComposerAttachments', () => {
api.call.mockImplementation((operation, input) => operation === 'confirmAttachment' ? confirmation.promise : base(operation, input));
const view = open();
const add = screen.getByRole('button', { name: '添加文件或图片' });
fireEvent.click(add); fireEvent.click(add);
await chooseFileFromMenu();
fireEvent.click(add);
expect(screen.queryAllByRole('menuitem')).toHaveLength(0);
expect(api.upload).toHaveBeenCalledTimes(1);
expect(add).toBeDisabled();
expect(screen.getByLabelText('消息')).toBeEnabled();
@@ -193,11 +206,18 @@ describe('CloudComposerAttachments', () => {
const remove = await screen.findByRole('button', { name: '移除附件 ' + photo.file_name });
expect(remove).toBeDisabled();
fireEvent.click(remove);
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
fireEvent.click(screen.getByRole('button', { name: '对话文件' }));
const add = screen.getByRole('button', { name: '添加文件或图片' });
expect(add).toBeDisabled();
fireEvent.click(add);
expect(screen.queryAllByRole('menuitem')).toHaveLength(0);
await openLibrary(1);
const choice = screen.getByRole('checkbox', { name: '选择附件 ' + photo.file_name });
expect(choice).toBeDisabled();
fireEvent.click(choice);
const save = screen.getByRole('button', { name: '保存 ' + photo.file_name });
expect(save).toBeDisabled();
fireEvent.click(save);
expect(api.download).not.toHaveBeenCalled();
await screen.findByText('还没有生成文件。');
expect(api.upload).not.toHaveBeenCalled();
expect(view.onSelected).not.toHaveBeenCalled();
@@ -207,7 +227,7 @@ describe('CloudComposerAttachments', () => {
const picker = deferred<CloudUpload | null>();
api.upload.mockReturnValue(picker.promise);
const view = open();
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
await chooseFileFromMenu();
expect(view.hasPending()).toBe(true);
view.unmount();
expect(view.onBusy.mock.calls).toEqual([[true], [false]]);
@@ -223,7 +243,7 @@ describe('CloudComposerAttachments', () => {
const base = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'confirmAttachment' ? confirmation.promise : base(operation, input));
const view = open();
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
await chooseFileFromMenu();
await waitFor(() => expect(operationCalls('confirmAttachment')).toHaveLength(1));
view.unmount();
await act(async () => { confirmation.resolve({ attachments: [photo] }); });
@@ -236,22 +256,31 @@ describe('CloudComposerAttachments', () => {
const list = deferred<{ attachments: CloudAttachment[] }>();
const longName = '小朋友写的故事'.repeat(30) + '.docx';
const longFile = { ...document, file_name: longName };
let reads = 0;
let retry = false;
let filesReads = 0;
api.call.mockImplementation(async (operation: string) => {
if (operation === 'attachments') return ++reads === 1 ? list.promise : { attachments: [longFile] };
if (operation === 'attachments') return retry ? { attachments: [longFile] } : list.promise;
if (operation === 'files' && ++filesReads === 1) throw new Error('文件列表首次读取中断');
return { files: [] };
});
const view = open({ threadId: 'thread-existing', initialSelected: [document.file_id] });
fireEvent.click(screen.getByRole('button', { name: '对话文件' }));
expect(screen.getByText('正在读取文件…')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^附件/ })).not.toBeInTheDocument();
// A failed companion list opens the library while the upload list is still loading.
fireEvent.click(await screen.findByRole('button', { name: '附件', exact: true }));
const dialog = await screen.findByRole('dialog', { name: '本次对话的附件' });
expect(within(dialog).getAllByText('正在读取文件…').length).toBeGreaterThan(0);
expect(within(dialog).queryByText('还没有上传文件。')).not.toBeInTheDocument();
await act(async () => { list.reject(new Error('文件读取失败')); });
expect(await screen.findByRole('alert')).toHaveTextContent('文件读取失败');
expect(await within(dialog).findByRole('alert')).toHaveTextContent('文件读取失败');
expect(screen.queryByText('还没有上传文件。')).not.toBeInTheDocument();
expect(view.onSelected).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '刷新文件' }));
retry = true;
fireEvent.click(within(dialog).getByRole('button', { name: '刷新附件' }));
expect(await within(dialog).findByRole('checkbox', { name: '选择附件 ' + longName })).toBeChecked();
await waitFor(() => expect(within(dialog).queryByRole('alert')).not.toBeInTheDocument());
fireEvent.click(within(dialog).getByRole('button', { name: '返回聊天 · 已选 1 个附件' }));
const remove = await screen.findByRole('button', { name: '移除附件 ' + longName });
expect(within(remove.closest('.agent-attachment-card') as HTMLElement).getByTitle(longName)).toHaveClass('agent-attachment-name');
expect(screen.getByRole('checkbox', { name: '选择附件 ' + longName })).toBeChecked();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(view.submit).not.toHaveBeenCalled();
});
@@ -259,7 +288,7 @@ describe('CloudComposerAttachments', () => {
it('lets the user retry or cancel a picker failure without touching existing input', async () => {
api.upload.mockRejectedValueOnce(new Error('上传失败')).mockResolvedValue(null);
const view = open({ initialSelected: ['existing-id'] });
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
await chooseFileFromMenu();
await screen.findByText('上传失败');
fireEvent.click(screen.getByRole('button', { name: '重试选择文件' }));
await waitFor(() => expect(api.upload).toHaveBeenCalledTimes(2));
@@ -278,8 +307,7 @@ describe('CloudComposerAttachments', () => {
return base(operation, input);
});
const view = open();
fireEvent.click(screen.getByRole('button', { name: '对话文件' }));
fireEvent.click(screen.getByRole('button', { name: '添加并选择读取方式' }));
await chooseFileFromMenu('添加并选择读取方式');
const choice = await screen.findByRole('combobox', { name: '附件读取方式' });
expect(operationCalls('confirmAttachment')).toHaveLength(0);
expect(operationCalls('parseAttachment')).toHaveLength(0);
@@ -298,18 +326,19 @@ describe('CloudComposerAttachments', () => {
expect(api.upload).toHaveBeenCalledTimes(1);
});
it('browses generated files and uses the existing native save path', async () => {
it('collects nested generated files and uses the existing native save path', async () => {
api.call.mockImplementation(async (operation: string, input: { path?: string }) => {
if (operation === 'attachments') return { attachments: [] };
if (input.path === '/') return { files: [{ name: '作品', path: '/作品', directory_path: '/作品', is_dir: true, size: 0 }] };
return { files: [{ name: '故事.txt', path: '/作品/故事.txt', directory_path: '/作品', is_dir: false, size: 500 }] };
});
const view = open({ threadId: 'thread-existing' });
fireEvent.click(screen.getByRole('button', { name: '对话文件' }));
fireEvent.click(await screen.findByRole('button', { name: '作品' }));
await openLibrary(1);
expect(api.call).toHaveBeenCalledWith('files', { thread_id: 'thread-existing', path: '/作品' });
fireEvent.click(await screen.findByRole('button', { name: '保存 故事.txt' }));
await waitFor(() => expect(api.download).toHaveBeenCalledWith('thread-existing', '/作品/故事.txt'));
expect(screen.getByRole('button', { name: '上一级' })).toHaveAttribute('type', 'button');
expect(screen.queryByRole('button', { name: '作品' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '上一级' })).not.toBeInTheDocument();
expect(view.ensureThread).not.toHaveBeenCalled();
expect(view.submit).not.toHaveBeenCalled();
});