feat(agents): move conversation attachments into a header side panel
This commit is contained in:
1 parent
e9f204776a
commit
0d54690dd6
10 files changed
+589
-98
No files matched your search
@@ -0,0 +1,261 @@
|
||||
import { useState, type ComponentProps } from 'react';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { CloudComposerAttachments } from '@/pages/CloudAgents/CloudComposerAttachments';
|
||||
import type { CloudAttachment, CloudFile } from '../../shared/cloud-agents';
|
||||
|
||||
const api = vi.hoisted(() => ({ call: vi.fn(), upload: vi.fn(), download: vi.fn() }));
|
||||
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
|
||||
const ensureThread = vi.fn();
|
||||
const onBusy = vi.fn();
|
||||
|
||||
const upload: CloudAttachment = {
|
||||
file_id: 'upload-1', file_name: 'notes.txt', file_size: 1024,
|
||||
path: '/notes.md', original_path: '/notes.txt',
|
||||
};
|
||||
function file(path: string): CloudFile {
|
||||
return { name: path.split('/').at(-1)!, path, directory_path: path.split('/').slice(0, -1).join('/') || '/', is_dir: false, size: 2048 };
|
||||
}
|
||||
function folder(path: string): CloudFile {
|
||||
return { name: path.split('/').at(-1)!, path, directory_path: path, is_dir: true, size: 0 };
|
||||
}
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
type HarnessProps = Pick<ComponentProps<typeof CloudComposerAttachments>, 'threadId' | 'headerTarget' | 'filesRevision' | 'activeView'> & { initialSelected?: string[] };
|
||||
function Harness({ initialSelected = [], ...props }: HarnessProps) {
|
||||
const [selected, setSelected] = useState(initialSelected);
|
||||
return <CloudComposerAttachments {...props} ensureThread={ensureThread} selected={selected} onSelected={setSelected} onBusy={onBusy} disabled={false}>
|
||||
<textarea aria-label="消息" />
|
||||
</CloudComposerAttachments>;
|
||||
}
|
||||
|
||||
async function openLibrary(count: number) {
|
||||
const trigger = await screen.findByRole('button', { name: `附件,共 ${count} 个文件` });
|
||||
fireEvent.click(trigger);
|
||||
return screen.findByRole('dialog', { name: '本次对话的附件' });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
ensureThread.mockResolvedValue('created-thread');
|
||||
api.download.mockResolvedValue({ saved: true });
|
||||
api.call.mockImplementation(async (operation: string) => {
|
||||
if (operation === 'attachments') return { attachments: [] };
|
||||
if (operation === 'files') return { files: [] };
|
||||
throw new Error('Unexpected operation: ' + operation);
|
||||
});
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
it.each([undefined, 'empty-thread'])('hides the library for an empty conversation (%s) and keeps adding available', async threadId => {
|
||||
render(<Harness threadId={threadId} />);
|
||||
if (threadId) {
|
||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('files', { thread_id: threadId, path: '/' }));
|
||||
await act(async () => undefined);
|
||||
}
|
||||
expect(screen.queryByRole('button', { name: /^附件/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '添加文件或图片' })).toBeEnabled();
|
||||
expect(api.upload).not.toHaveBeenCalled();
|
||||
expect(ensureThread).not.toHaveBeenCalled();
|
||||
if (!threadId) expect(api.call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts generated files in nested folders once and offers the library without uploaded files', async () => {
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'attachments') return { attachments: [] };
|
||||
if (operation === 'files') return { files: input.path === '/'
|
||||
? [file('/poster.png'), folder('/outputs')]
|
||||
: input.path === '/outputs'
|
||||
? [file('/outputs/story.txt'), folder('/outputs/audio'), folder('/outputs'), file('/poster.png')]
|
||||
: [file('/outputs/audio/story.mp3')] };
|
||||
throw new Error(operation);
|
||||
});
|
||||
render(<Harness threadId="generated-thread" />);
|
||||
const dialog = await openLibrary(3);
|
||||
const generated = within(dialog).getByRole('region', { name: '助手生成的' });
|
||||
expect(within(generated).getAllByRole('button', { name: /^保存 / })).toHaveLength(3);
|
||||
expect(within(generated).getByText('story.mp3')).toBeVisible();
|
||||
expect(within(dialog).getByText('还没有上传文件。')).toBeVisible();
|
||||
expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument();
|
||||
expect(api.call).toHaveBeenCalledWith('files', { thread_id: 'generated-thread', path: '/outputs/audio' });
|
||||
expect(ensureThread).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('excludes uploaded and parsed copies from generated files and saves each original scoped path without selecting it', async () => {
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'attachments') return { attachments: [upload] };
|
||||
if (operation === 'files') return { files: input.path === '/'
|
||||
? [file('/notes.txt'), file('/notes.md'), folder('/outputs')]
|
||||
: [file('/outputs/story.txt')] };
|
||||
throw new Error(operation);
|
||||
});
|
||||
render(<Harness threadId="download-thread" />);
|
||||
const dialog = await openLibrary(2);
|
||||
const uploaded = within(dialog).getByRole('region', { name: '你上传的' });
|
||||
const generated = within(dialog).getByRole('region', { name: '助手生成的' });
|
||||
expect(within(generated).queryByText('notes.txt')).not.toBeInTheDocument();
|
||||
expect(within(generated).queryByText('notes.md')).not.toBeInTheDocument();
|
||||
expect(within(generated).getAllByRole('button', { name: /^保存 / })).toHaveLength(1);
|
||||
fireEvent.click(within(uploaded).getByRole('button', { name: '保存 notes.txt' }));
|
||||
await waitFor(() => expect(api.download).toHaveBeenCalledWith('download-thread', '/notes.txt'));
|
||||
await waitFor(() => expect(within(generated).getByRole('button', { name: '保存 story.txt' })).toBeEnabled());
|
||||
fireEvent.click(within(generated).getByRole('button', { name: '保存 story.txt' }));
|
||||
await waitFor(() => expect(api.download).toHaveBeenCalledWith('download-thread', '/outputs/story.txt'));
|
||||
expect(within(uploaded).getByRole('checkbox', { name: '选择附件 notes.txt' })).not.toBeChecked();
|
||||
expect(screen.queryByLabelText('本条消息的附件')).not.toBeInTheDocument();
|
||||
expect(api.download).toHaveBeenCalledTimes(2);
|
||||
expect(api.call.mock.calls.some(([operation]) => operation === 'deleteAttachment')).toBe(false);
|
||||
});
|
||||
|
||||
it('portals the entry into the identity header, moves focus into the Sheet and restores it after Escape', async () => {
|
||||
api.call.mockImplementation(async operation => operation === 'attachments' ? { attachments: [upload] } : { files: [] });
|
||||
const header = document.createElement('header');
|
||||
document.body.appendChild(header);
|
||||
try {
|
||||
const view = render(<Harness threadId="focus-thread" headerTarget={header} />);
|
||||
const trigger = await screen.findByRole('button', { name: '附件,共 1 个文件' });
|
||||
expect(header).toContainElement(trigger);
|
||||
expect(view.container).not.toContainElement(trigger);
|
||||
trigger.focus();
|
||||
fireEvent.click(trigger);
|
||||
const dialog = await screen.findByRole('dialog', { name: '本次对话的附件' });
|
||||
expect(view.container).not.toContainElement(dialog);
|
||||
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
|
||||
expect(dialog).toHaveAccessibleDescription('1 个文件,随时找回来。');
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
} finally {
|
||||
cleanup();
|
||||
header.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps known uploads, generated files and message selections when refresh fails, then clears errors on retry', async () => {
|
||||
let fail = false;
|
||||
api.call.mockImplementation(async operation => {
|
||||
if (fail) throw new Error(operation === 'attachments' ? '上传列表暂不可用' : '生成列表暂不可用');
|
||||
return operation === 'attachments' ? { attachments: [upload] } : { files: [file('/poster.png')] };
|
||||
});
|
||||
render(<Harness threadId="refresh-thread" initialSelected={[upload.file_id]} />);
|
||||
const dialog = await openLibrary(2);
|
||||
await screen.findByRole('checkbox', { name: '选择附件 notes.txt' });
|
||||
fail = true;
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '刷新附件' }));
|
||||
expect(await within(dialog).findByText('上传列表暂不可用')).toBeVisible();
|
||||
expect(await within(dialog).findByText('生成列表暂不可用')).toBeVisible();
|
||||
expect(within(dialog).getByRole('checkbox', { name: '选择附件 notes.txt' })).toBeChecked();
|
||||
expect(within(dialog).getByRole('button', { name: '保存 poster.png' })).toBeEnabled();
|
||||
fail = false;
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '刷新附件' }));
|
||||
await waitFor(() => expect(within(dialog).queryByRole('alert')).not.toBeInTheDocument());
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '返回聊天 · 已选 1 个附件' }));
|
||||
expect(await screen.findByRole('button', { name: '移除附件 notes.txt' })).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '附件,共 2 个文件' })).toBeVisible();
|
||||
});
|
||||
|
||||
it.each(['attachments', 'files'] as const)('does not claim a complete count or unknown file origin after the first %s read fails, then recovers on refresh', async failedOperation => {
|
||||
let fail = true;
|
||||
const error = failedOperation === 'attachments' ? '上传列表首次读取失败' : '文件列表首次读取失败';
|
||||
api.call.mockImplementation(async operation => {
|
||||
if (fail && operation === failedOperation) throw new Error(error);
|
||||
return operation === 'attachments' ? { attachments: [upload] } : { files: [file('/notes.txt'), file('/notes.md'), file('/poster.png')] };
|
||||
});
|
||||
render(<Harness threadId="first-read-thread" />);
|
||||
const trigger = await screen.findByRole('button', { name: '附件', exact: true });
|
||||
await act(async () => undefined);
|
||||
expect(trigger).toHaveTextContent(/^附件$/);
|
||||
expect(screen.queryByRole('button', { name: /^附件,共/ })).not.toBeInTheDocument();
|
||||
fireEvent.click(trigger);
|
||||
const dialog = await screen.findByRole('dialog', { name: '本次对话的附件' });
|
||||
expect(await within(dialog).findByText(error)).toBeVisible();
|
||||
expect(dialog).toHaveAccessibleDescription('这次聊天用到的文件都在这里。');
|
||||
const generated = within(dialog).getByRole('region', { name: '助手生成的' });
|
||||
expect(within(generated).queryByText('notes.txt')).not.toBeInTheDocument();
|
||||
expect(within(generated).queryByText('notes.md')).not.toBeInTheDocument();
|
||||
expect(within(generated).queryByText('poster.png')).not.toBeInTheDocument();
|
||||
expect(within(generated).queryByText('还没有生成文件。')).not.toBeInTheDocument();
|
||||
if (failedOperation === 'attachments') {
|
||||
expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument();
|
||||
expect(within(dialog).queryByText('还没有上传文件。')).not.toBeInTheDocument();
|
||||
} else {
|
||||
expect(within(dialog).getByRole('checkbox', { name: '选择附件 notes.txt' })).toBeVisible();
|
||||
expect(within(generated).getByRole('alert')).toHaveTextContent(error);
|
||||
}
|
||||
fail = false;
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '刷新附件' }));
|
||||
await waitFor(() => expect(dialog).toHaveAccessibleDescription('2 个文件,随时找回来。'));
|
||||
expect(within(dialog).getByRole('checkbox', { name: '选择附件 notes.txt' })).toBeVisible();
|
||||
expect(within(generated).getByRole('button', { name: '保存 poster.png' })).toBeEnabled();
|
||||
expect(within(generated).getAllByRole('button', { name: /^保存 / })).toHaveLength(1);
|
||||
expect(within(dialog).queryByRole('alert')).not.toBeInTheDocument();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '返回聊天' }));
|
||||
expect(await screen.findByRole('button', { name: '附件,共 2 个文件' })).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('discards late files and uploads after switching conversations and stops reading old subfolders', async () => {
|
||||
const oldUploads = deferred<{ attachments: CloudAttachment[] }>();
|
||||
const oldFiles = deferred<{ files: CloudFile[] }>();
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (input.thread_id === 'old-thread') return operation === 'attachments' ? oldUploads.promise : oldFiles.promise;
|
||||
return operation === 'attachments' ? { attachments: [{ ...upload, file_id: 'new-upload', file_name: 'new-notes.txt' }] } : { files: [file('/new-poster.png')] };
|
||||
});
|
||||
const view = render(<Harness threadId="old-thread" />);
|
||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('files', { thread_id: 'old-thread', path: '/' }));
|
||||
view.rerender(<Harness threadId="new-thread" />);
|
||||
const dialog = await openLibrary(2);
|
||||
expect(within(dialog).getByText('new-notes.txt')).toBeVisible();
|
||||
await act(async () => {
|
||||
oldUploads.resolve({ attachments: [upload] });
|
||||
oldFiles.resolve({ files: [file('/old-poster.png'), folder('/old-output')] });
|
||||
});
|
||||
expect(within(dialog).queryByText('notes.txt')).not.toBeInTheDocument();
|
||||
expect(within(dialog).queryByText('old-poster.png')).not.toBeInTheDocument();
|
||||
expect(within(dialog).getByText('new-poster.png')).toBeVisible();
|
||||
expect(api.call).not.toHaveBeenCalledWith('files', { thread_id: 'old-thread', path: '/old-output' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '保存 new-poster.png' }));
|
||||
await waitFor(() => expect(api.download).toHaveBeenCalledWith('new-thread', '/new-poster.png'));
|
||||
});
|
||||
|
||||
it('refreshes generated files when authoritative history revision changes while retaining the current conversation', async () => {
|
||||
let generated = false;
|
||||
api.call.mockImplementation(async operation => operation === 'attachments' ? { attachments: [] } : { files: generated ? [file('/answer.txt')] : [] });
|
||||
const view = render(<Harness threadId="revision-thread" filesRevision="message-1:run-1:running" />);
|
||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('files', { thread_id: 'revision-thread', path: '/' }));
|
||||
await act(async () => undefined);
|
||||
expect(screen.queryByRole('button', { name: /^附件/ })).not.toBeInTheDocument();
|
||||
generated = true;
|
||||
view.rerender(<Harness threadId="revision-thread" filesRevision="message-2:run-1:completed" />);
|
||||
const dialog = await openLibrary(1);
|
||||
expect(within(dialog).getByText('answer.txt')).toBeVisible();
|
||||
expect(api.call.mock.calls.filter(([operation, input]) => operation === 'files' && input.thread_id === 'revision-thread').length).toBeGreaterThanOrEqual(2);
|
||||
expect(ensureThread).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offers reading choices from the first upload and confirms the chosen method on the same created thread', async () => {
|
||||
api.upload.mockResolvedValue({ object_name: 'temporary/notes.txt', file_name: 'notes.txt', file_type: 'txt', parse_supported: true, parse_methods: ['disable', 'rapid_ocr'] });
|
||||
const base = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'parseAttachment') return { parsed_object_name: 'parsed/notes.md' };
|
||||
if (operation === 'confirmAttachment') return { attachments: [upload] };
|
||||
return base(operation, input);
|
||||
});
|
||||
render(<Harness />);
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: '添加文件或图片' }), { key: 'ArrowDown' });
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: '添加并选择读取方式' }));
|
||||
const method = await screen.findByRole('combobox', { name: '附件读取方式' });
|
||||
expect(ensureThread).not.toHaveBeenCalled();
|
||||
expect(api.call.mock.calls.some(([operation]) => operation === 'confirmAttachment')).toBe(false);
|
||||
fireEvent.change(method, { target: { value: 'rapid_ocr' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加到消息' }));
|
||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('confirmAttachment', { thread_id: 'created-thread', attachments: [{ object_name: 'temporary/notes.txt', file_type: 'txt', parsed_object_name: 'parsed/notes.md' }] }));
|
||||
expect(api.call).toHaveBeenCalledWith('parseAttachment', { object_name: 'temporary/notes.txt', parse_method: 'rapid_ocr' });
|
||||
expect(await screen.findByRole('button', { name: '移除附件 notes.txt' })).toBeVisible();
|
||||
expect(api.upload).toHaveBeenCalledTimes(1);
|
||||
expect(ensureThread).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -42,6 +42,11 @@ beforeEach(() => {
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
async function chooseFileFromMenu(button = screen.getByRole('button', { name: '添加文件或图片' })) {
|
||||
fireEvent.keyDown(button, { key: 'ArrowDown' });
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: '添加文件或图片', exact: true }));
|
||||
}
|
||||
|
||||
it('recovers a server queue and cancels it after remount without resubmitting messages', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
let cancelled = false;
|
||||
@@ -383,12 +388,12 @@ it('creates an attachment conversation only after a native pick and deselects th
|
||||
render(<CloudChat slug={slug} />);
|
||||
const add = screen.getByRole('button', { name: '添加文件或图片' });
|
||||
await waitFor(() => expect(add).toBeEnabled());
|
||||
fireEvent.click(add);
|
||||
await chooseFileFromMenu(add);
|
||||
await waitFor(() => expect(api.upload).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(add).toBeEnabled());
|
||||
expect(api.call.mock.calls.some(call => call[0] === 'createThread')).toBe(false);
|
||||
expect(screen.queryByLabelText('历史对话')).not.toBeInTheDocument();
|
||||
fireEvent.click(add);
|
||||
await chooseFileFromMenu(add);
|
||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('confirmAttachment', {
|
||||
thread_id: 'thread-1', attachments: [{ object_name: 'temporary/notes.txt', file_type: 'txt' }],
|
||||
}));
|
||||
@@ -400,8 +405,15 @@ it('creates an attachment conversation only after a native pick and deselects th
|
||||
fireEvent.click(remove);
|
||||
expect(screen.queryByRole('button', { name: '移除附件 notes.txt' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '发送', exact: true })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '对话文件' }));
|
||||
expect(await screen.findByRole('checkbox', { name: '选择附件 notes.txt' })).not.toBeChecked();
|
||||
fireEvent.click(screen.getByRole('button', { name: '附件,共 1 个文件' }));
|
||||
const checkbox = await screen.findByRole('checkbox', { name: '选择附件 notes.txt' });
|
||||
expect(checkbox).not.toBeChecked();
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox).toBeChecked();
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回聊天 · 已选 1 个附件' }));
|
||||
expect(await screen.findByRole('button', { name: '移除附件 notes.txt' })).toBeVisible();
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除附件 notes.txt' }));
|
||||
expect(screen.queryByRole('button', { name: '移除附件 notes.txt' })).not.toBeInTheDocument();
|
||||
expect(api.call.mock.calls.some(call => ['deleteAttachment', 'submit', 'preview'].includes(call[0]))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -419,7 +431,7 @@ it('blocks preview switching while creating an attachment thread and ignores a l
|
||||
api.upload.mockResolvedValue({ object_name: 'temporary/old-agent-notes.txt', file_name: 'old-agent-notes.txt', file_type: 'txt', parse_supported: false, parse_methods: [] });
|
||||
const view = render(<CloudChat slug={slug} previewRevision={1} />);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '添加文件或图片' })).toBeEnabled());
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
|
||||
await chooseFileFromMenu();
|
||||
await waitFor(() => expect(api.call.mock.calls.filter(call => call[0] === 'createThread')).toHaveLength(1));
|
||||
expect(screen.getByRole('button', { name: '新对话' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '新对话' }));
|
||||
@@ -456,7 +468,7 @@ it('retries the same picked attachment and thread after confirmation fails, then
|
||||
api.upload.mockResolvedValue({ object_name: 'temporary/notes.txt', file_name: 'notes.txt', file_type: 'txt', parse_supported: false, parse_methods: [] });
|
||||
render(<CloudChat slug={slug} />);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '添加文件或图片' })).toBeEnabled());
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
|
||||
await chooseFileFromMenu();
|
||||
const retry = await screen.findByRole('button', { name: '重试添加' });
|
||||
fireEvent.change(screen.getByLabelText('消息'), { target: { value: '请阅读这个附件' } });
|
||||
expect(screen.getByRole('button', { name: '发送', exact: true })).toBeDisabled();
|
||||
@@ -490,7 +502,7 @@ it('cancels an unconfirmed attachment without deleting it and permits the typed
|
||||
render(<CloudChat slug={slug} />);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '添加文件或图片' })).toBeEnabled());
|
||||
fireEvent.change(screen.getByLabelText('消息'), { target: { value: '继续讨论我的想法' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加文件或图片' }));
|
||||
await chooseFileFromMenu();
|
||||
await screen.findByRole('button', { name: '重试添加' });
|
||||
expect(screen.getByRole('button', { name: '发送', exact: true })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消添加' }));
|
||||
@@ -569,3 +581,30 @@ it('does not mark a retained hidden conversation as read until it is shown', asy
|
||||
view.rerender(<CloudChat slug={slug} activeView />);
|
||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('viewed', { thread_id: 'thread-1', run_id: 'run-1' }));
|
||||
});
|
||||
|
||||
it('discovers generated files after the authoritative completed reply without reopening the conversation', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
const events = new Stream();
|
||||
api.events.mockResolvedValue(events);
|
||||
let completed = false;
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'threads') return { threads: [{ thread_id: 'thread-1', client_thread_id: 'client', mode: 'published' }], next_offset: null };
|
||||
if (operation === 'history') return { ...history, messages: completed ? [{ id: 'answer-1', role: 'assistant', content: '故事文件已做好', run_id: 'run-1' }] : [], run: { ...run, status: completed ? 'completed' : 'running' } };
|
||||
if (operation === 'files') return { files: completed ? [{ name: 'story.txt', path: '/story.txt', directory_path: '/', is_dir: false, size: 1024 }] : [] };
|
||||
return base(operation, input);
|
||||
});
|
||||
render(<CloudChat slug={slug} />);
|
||||
await waitFor(() => expect(api.events).toHaveBeenCalledWith('run-1'));
|
||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('files', { thread_id: 'thread-1', path: '/' }));
|
||||
expect(screen.queryByRole('button', { name: /^附件/ })).not.toBeInTheDocument();
|
||||
completed = true;
|
||||
await act(async () => events.dispatchEvent(new MessageEvent('end', {
|
||||
data: JSON.stringify({ run_id: 'run-1', thread_id: 'thread-1', payload: { chunk: { status: 'completed' } } }),
|
||||
})));
|
||||
expect(await screen.findByText('故事文件已做好')).toBeVisible();
|
||||
fireEvent.click(await screen.findByRole('button', { name: '附件,共 1 个文件' }));
|
||||
expect(await screen.findByRole('button', { name: '保存 story.txt' })).toBeEnabled();
|
||||
expect(api.call.mock.calls.filter(([operation]) => operation === 'history')).toHaveLength(2);
|
||||
expect(api.call.mock.calls.some(([operation]) => ['createThread', 'confirmAttachment', 'submit'].includes(operation))).toBe(false);
|
||||
expect(api.upload).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user