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);