fix: await concurrent attachment uploads

This commit is contained in:
2026-08-24 01:54:25 +08:00
parent 1ca0249e69
commit 1fe8863e69
3 changed files with 20 additions and 9 deletions

View File

@@ -156,7 +156,8 @@
acceptance settle, so removal or a next-message paste cannot mutate the
captured image set. Attachment preparation failures now remain local to the
Composer, restore the draft, permit direct retry without runtime recovery,
and retain already uploaded variants for reuse. Attachment route failures use
retain already uploaded variants for reuse, and wait for every concurrent
upload flight to settle before releasing the edit lock. Attachment route failures use
only registered `CODING_ATTACHMENT_INVALID`, `CODING_ATTACHMENT_NOT_FOUND`,
and `CODING_STORAGE_WRITE_FAILED` codes while preserving 413/404 status.
- Synchronized `README.md` with the current Pi core Chat, first-Conversation,

View File

@@ -392,7 +392,10 @@ export function CodingChatPanel({
};
}
});
await Promise.all(workers);
const results = await Promise.allSettled(workers);
for (const result of results) {
if (result.status === 'rejected') throw result.reason;
}
return prepared;
}, []);

View File

@@ -365,7 +365,7 @@ describe('CodingChatPanel first Conversation', () => {
expect(maxActiveUploads).toBeLessThanOrEqual(4);
});
it('locks attachment removal while an upload is still pending', async () => {
it('keeps attachment removal locked until every upload settles after one fails', async () => {
const upload = deferred<{ attachmentId: string; mime: string; byteLength: number }>();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
@@ -383,7 +383,11 @@ describe('CodingChatPanel first Conversation', () => {
runId: 'run-1',
mode: input.mode,
}));
attachmentApi.upload.mockReturnValue(upload.promise);
attachmentApi.upload.mockImplementation((file: File) => (
file.name === 'failed.png'
? Promise.reject(new Error('图片附件无效。'))
: upload.promise
));
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
@@ -393,21 +397,24 @@ describe('CodingChatPanel first Conversation', () => {
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(conversation.id));
const file = new File(['image'], 'held.png', { type: 'image/png' });
const failed = new File(['failed'], 'failed.png', { type: 'image/png' });
const held = new File(['held'], 'held.png', { type: 'image/png' });
fireEvent.change(screen.getByTestId('coding-file-attachment-input'), {
target: { files: [file] },
target: { files: [failed, held] },
});
fireEvent.click(await screen.findByRole('button', { name: '发送' }));
await waitFor(() => expect(attachmentApi.upload).toHaveBeenCalledOnce());
await waitFor(() => expect(attachmentApi.upload).toHaveBeenCalledTimes(2));
const remove = screen.getByRole('button', { name: '移除图片 held.png' });
expect(remove).toBeDisabled();
fireEvent.click(remove);
expect(screen.getByAltText('held.png')).toBeInTheDocument();
expect(screen.queryByText('图片附件无效。')).not.toBeInTheDocument();
upload.resolve({ attachmentId: 'attachment-held', mime: 'image/png', byteLength: 5 });
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce());
await waitFor(() => expect(screen.queryByAltText('held.png')).not.toBeInTheDocument());
expect(await screen.findByText('图片附件无效。')).toBeInTheDocument();
expect(remove).toBeEnabled();
expect(conversationApi.submit).not.toHaveBeenCalled();
});
it('keeps next-message images out of a submission while its 202 response is pending', async () => {