diff --git a/.project-docs/30-worklog/tasks/20260823-pi-chat-timeline-c8f31a62.md b/.project-docs/30-worklog/tasks/20260823-pi-chat-timeline-c8f31a62.md index 253fb89b..46a8106a 100644 --- a/.project-docs/30-worklog/tasks/20260823-pi-chat-timeline-c8f31a62.md +++ b/.project-docs/30-worklog/tasks/20260823-pi-chat-timeline-c8f31a62.md @@ -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, diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index 318ac28d..f4b7f52e 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -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; }, []); diff --git a/tests/unit/coding-chat-panel.test.tsx b/tests/unit/coding-chat-panel.test.tsx index d2f6c11c..f43a4a3a 100644 --- a/tests/unit/coding-chat-panel.test.tsx +++ b/tests/unit/coding-chat-panel.test.tsx @@ -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 () => {