From 43514ab3cbf7a1eda194e6cb943c3237ea0e4742 Mon Sep 17 00:00:00 2001 From: Mihidum Hettiyahandi <55163074+mihidumh@users.noreply.github.com> Date: Thu, 28 May 2026 09:12:44 +1000 Subject: [PATCH] fix(elements): render file elements with null mime instead of crashing A persisted file element with a null mime crashed the whole thread view with "Cannot read properties of null (reading 'startsWith')". The shared Attachment component dereferenced mime unguarded (regression from #2783), and File.tsx passed element.mime via a non-null assertion despite mime being optional at runtime. Guard the mime access in Attachment, drop the unsafe assertion in File.tsx, and add a filename-based mime fallback in Element.send() so text-based files (.md, .csv, ...) that filetype.guess() can't detect get a sensible mime instead of being persisted as NULL. Fixes #2938 Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/chainlit/element.py | 6 +++++ backend/tests/test_element.py | 25 +++++++++++++++++++ frontend/src/components/Elements/File.tsx | 2 +- .../chat/MessageComposer/Attachment.tsx | 4 +-- 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/backend/chainlit/element.py b/backend/chainlit/element.py index 901eae2980..001a4c76d4 100644 --- a/backend/chainlit/element.py +++ b/backend/chainlit/element.py @@ -241,6 +241,12 @@ async def send(self, for_id: str, persist=True): file_type = filetype.guess(self.path or self.content) if file_type: self.mime = file_type.mime + else: + # filetype.guess detects by magic bytes only, so text-based + # files (.md, .csv, .txt, source code, ...) return None. + # Fall back to filename-based detection so they still get a + # sensible mime instead of being persisted as NULL. + self.mime = mimetypes.guess_type(self.path or self.name)[0] elif self.url: self.mime = mimetypes.guess_type(self.url)[0] diff --git a/backend/tests/test_element.py b/backend/tests/test_element.py index 0a6bbd2bc4..0cbf3c7371 100644 --- a/backend/tests/test_element.py +++ b/backend/tests/test_element.py @@ -313,6 +313,31 @@ async def test_file_with_content(self, mock_chainlit_context): assert file.content == content + async def test_file_mime_falls_back_to_filename(self, mock_chainlit_context): + """Text-based content has no magic bytes, so filetype.guess() returns + None. send() should fall back to filename-based detection instead of + persisting a null mime (which crashed thread rendering, see #2938).""" + async with mock_chainlit_context: + file = File(name="notes.md", content=b"# hello\nmarkdown content") + + await file.send(for_id="message_123") + + assert file.mime == "text/markdown" + + async def test_file_mime_falls_back_to_path_filename( + self, mock_chainlit_context, tmp_path + ): + """When only a path is provided, the filename fallback uses the path.""" + csv_path = tmp_path / "export.csv" + csv_path.write_text("a,b\n1,2\n") + + async with mock_chainlit_context: + file = File(name="data", path=str(csv_path)) + + await file.send(for_id="message_123") + + assert file.mime == "text/csv" + @pytest.mark.asyncio class TestTaskListElement: diff --git a/frontend/src/components/Elements/File.tsx b/frontend/src/components/Elements/File.tsx index f642beece1..e5d1b52b25 100644 --- a/frontend/src/components/Elements/File.tsx +++ b/frontend/src/components/Elements/File.tsx @@ -14,7 +14,7 @@ const FileElement = ({ element }: { element: IFileElement }) => { href={element.url} target="_blank" > - + ); }; diff --git a/frontend/src/components/chat/MessageComposer/Attachment.tsx b/frontend/src/components/chat/MessageComposer/Attachment.tsx index d5087044e2..5ee17504a3 100644 --- a/frontend/src/components/chat/MessageComposer/Attachment.tsx +++ b/frontend/src/components/chat/MessageComposer/Attachment.tsx @@ -11,7 +11,7 @@ import { interface AttachmentProps { name: string; - mime: string; + mime?: string; children?: React.ReactNode; file?: File; } @@ -22,7 +22,7 @@ const Attachment: React.FC = ({ children, file }) => { - const isImage = useMemo(() => mime.startsWith('image/'), [mime]); + const isImage = useMemo(() => !!mime?.startsWith('image/'), [mime]); const imageUrl = useMemo(() => { if (isImage && file) { return URL.createObjectURL(file);