diff --git a/.changeset/fn-8502-chat-quick-add-attachments.md b/.changeset/fn-8502-chat-quick-add-attachments.md new file mode 100644 index 0000000000..859898a7df --- /dev/null +++ b/.changeset/fn-8502-chat-quick-add-attachments.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add photo and file attachments to Quick Add and Main Chat. +category: feature +dev: Quick Add now aligns picker, paste, and drop MIME intake with task-store attachment support. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index a20c02a93b..6ad2a32416 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -567,7 +567,7 @@ Create requests never send an explicit `column`. The task store resolves the lan Optional workflow steps declared by the active workflow are available from the quick-add action row and the **New Task** dialog's inline quick buttons. For example, the coding workflow's browser verification option appears as a quick drop-down when that workflow is active; each option is seeded from the workflow step's `defaultOn` setting and is sent with the task's `enabledWorkflowSteps` payload at creation time. - + Quick Add and Inline Create model selection include Plan, Executor, Reviewer, and Merger lanes. Each lane can inherit its default or select a task-specific model; Plan, Reviewer, and Merger also provide independent thinking-level overrides. @@ -576,7 +576,7 @@ Quick Add and Inline Create model selection include Plan, Executor, Reviewer, an Quick Add **Save** supports a **Start** menu on touch/pen long-press or mouse right-click only when the exact selected workflow has complete runtime metadata: a real non-sentinel id, nonempty ordered columns with unique nonblank ids, and an object `flags` value on every column. It is eligible only for validated `builtin:coding-ideas` or a validated workflow whose first visible column is a hold. Start snapshots that exact definition and id before duplicate confirmation and submits it unchanged; later selection or metadata refreshes cannot alter promotion. A returned create result that is absent, malformed, missing an id/column/workflow id, or mismatched to the submitted workflow is still a successful create-only result. For a matching result, Start moves through the host Board/List move path only to the first later visible working column, skipping intake, hold, and complete columns; no forward target also remains create-only. -Quick Add image attachments use the paperclip icon button in the action row. Supported image files (`png`, `jpeg`, `gif`, `webp`) can be selected from that control, pasted into the Quick Add input, or dragged onto the Quick Add box; all three paths show compact pending previews before task creation and upload the images to the created task afterward. Select a pending preview to inspect the full image in a movable, resizable window (a full-screen sheet on mobile); close it with Escape or the close control to return to the preview. The same bottom action row places the GitHub tracking override beside the paperclip; Priority is an icon-only control whose glyph changes by selected level (down arrow for low, flag for normal, up arrow for high, alert for urgent) and is color-coded by urgency (low blue/info, normal muted, high amber/warning, urgent red/error), and Fast is an icon-only lightning control. These icon-only controls keep accessible labels and the same create-payload behavior as the previous text chips. +Quick Add's paperclip accepts supported photos and files: PNG, JPEG, GIF, WebP, MP4, WebM, QuickTime video, plain text, Markdown, JSON, YAML, TOML, CSV, and XML. Select files, paste them into the Quick Add input, or drag them onto the box; pending attachments upload to the newly created task sequentially. Image attachments show compact previews that open in a movable, resizable window (a full-screen sheet on mobile); file attachments show an accessible filename and remove action without an image-open control. Unsupported selections are ignored, and if one upload fails after task creation, the task remains created while Quick Add reports the filenames that need retrying. The same bottom action row places the GitHub tracking override beside the paperclip; Priority is an icon-only control whose glyph changes by selected level (down arrow for low, flag for normal, up arrow for high, alert for urgent) and is color-coded by urgency (low blue/info, normal muted, high amber/warning, urgent red/error), and Fast is an icon-only lightning control. These icon-only controls keep accessible labels and the same create-payload behavior as the previous text chips. Quick entry, inline quick-create, and the full **New Task** dialog all check for similar active tasks before creating. When possible duplicates exist, the warning lists each match by task description (falling back to title, then “No description”) and lets you open an existing task, cancel, or create anyway with the duplicates acknowledged. @@ -652,6 +652,7 @@ Chat view provides project-scoped conversations with agents. - Assistant question tool calls now render as a shared in-chat response card instead of a generic tool-call disclosure. The card recognizes provider-native question tools and Fusion's `fn_ask_question`, supports select, multi-select, text, and yes/no prompts, sends the formatted answer back into the same direct or room thread, and renders historical answered questions read-only. - The desktop Chat view toggle and mobile Chat tab now show an unread-response indicator when a live assistant reply arrives for a visible direct or room chat after you leave Chat; opening Chat clears it immediately. Task-detail planner Chat replies stay task-local and do not light up the global Chat unread indicator while those sessions are hidden from the common Chat feed. - Agent-backed chat sessions now expose the same mailbox messaging tools (`fn_send_message`, `fn_read_messages`) used by runtime execution/heartbeat flows whenever the engine `MessageStore` is available; model-only chats continue to run without mailbox tools. +- Main Chat and Chat Rooms accept the same supported photos and files as Quick Add through the paperclip, clipboard paste, or drag/drop. Pending images retain their preview/open behavior, while non-image files remain removable filename chips; failed sends preserve staged attachments for retry. - Chat attachments are included in agent-visible prompts for both direct sessions and rooms: supported text attachments are appended under an `Attachments` prompt section, and supported images (`png`, `jpeg`, `gif`, `webp`) are passed as image inputs to the model. - Chat attachments can be sent without accompanying text in both Quick Chat and Main Chat; fully empty sends with no text and no attachments are still blocked. diff --git a/packages/dashboard/app/__tests__/quick-entry-image-preview-modal.test.tsx b/packages/dashboard/app/__tests__/quick-entry-attachment-preview-modal.test.tsx similarity index 50% rename from packages/dashboard/app/__tests__/quick-entry-image-preview-modal.test.tsx rename to packages/dashboard/app/__tests__/quick-entry-attachment-preview-modal.test.tsx index c474bdcf7d..aca3e7412b 100644 --- a/packages/dashboard/app/__tests__/quick-entry-image-preview-modal.test.tsx +++ b/packages/dashboard/app/__tests__/quick-entry-attachment-preview-modal.test.tsx @@ -1,14 +1,14 @@ import { describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { PendingImagePreviews } from "../components/PendingImagePreviews"; +import { PendingAttachmentPreviews } from "../components/PendingAttachmentPreviews"; const image = { file: new File(["image"], "preview.png", { type: "image/png" }), previewUrl: "blob:preview", }; -describe("pending task image previews", () => { +describe("pending task attachment previews", () => { it.each([ "quick-entry-preview", "task-form-preview", @@ -17,8 +17,8 @@ describe("pending task image previews", () => { const user = userEvent.setup(); const onRemove = vi.fn(); render( - { expect(screen.queryByTestId("floating-window-pending-image-preview")).toBeNull(); }); - it("renders no preview shells without pending images", () => { + it("renders a non-image filename with removal but no image-open button", () => { + const onRemove = vi.fn(); + render( + , + ); + + expect(screen.getByTestId("quick-entry-preview-file-0")).toHaveTextContent("notes.txt"); + expect(screen.queryByTestId("quick-entry-preview-open-0")).toBeNull(); + expect(screen.getByTestId("quick-entry-preview-remove-0")).toHaveAccessibleName("Remove attachment: notes.txt"); + fireEvent.click(screen.getByTestId("quick-entry-preview-remove-0")); + expect(onRemove).toHaveBeenCalledWith(0); + }); + + it("renders mixed attachments without empty preview controls and disables removals when requested", () => { + render( + , + ); + + expect(screen.getByTestId("quick-entry-preview-open-0")).toBeInTheDocument(); + expect(screen.getByTestId("quick-entry-preview-file-1")).toHaveTextContent("data.json"); + expect(screen.queryByTestId("quick-entry-preview-open-1")).toBeNull(); + expect(screen.getByTestId("quick-entry-preview-remove-0")).toBeDisabled(); + expect(screen.getByTestId("quick-entry-preview-remove-1")).toBeDisabled(); + }); + + it("renders no preview shells without pending attachments", () => { const { container } = render( - ) => { - const clipboardFiles = event.clipboardData?.files; - if (!clipboardFiles || clipboardFiles.length === 0) return; - const imageFiles = Array.from(clipboardFiles).filter((file) => file.type.startsWith("image/")); - if (imageFiles.length === 0) return; - handleAttachmentFiles(imageFiles); + /* + FNXC:ChatAttachments 2026-08-03-00:00: + Chat paste must use the same MIME validation path as picker and drop. Filtering clipboard data + to images made supported text files disappear before the authoritative server validation ran. + */ + handleAttachmentFiles(event.clipboardData?.files); }, [handleAttachmentFiles]); // Handle create session @@ -1858,8 +1868,34 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout return; } - clearComposerState(); - sendMessage(trimmed, files); + const sentFiles = new Set(files); + /* + FNXC:QuickAddAttachments 2026-08-03-00:00: + Keep direct-chat previews alive until useChat confirms multipart delivery. A direct-session + upload fails asynchronously, so clearComposerState() here would revoke the only retryable File + references before its stream error handler can report the rejection. + */ + setMessageInput(""); + try { + sendMessage(trimmed, files, { + onDelivered: () => { + setPendingAttachments((prev) => { + for (const attachment of prev) { + if (sentFiles.has(attachment.file) && attachment.previewUrl) { + URL.revokeObjectURL(attachment.previewUrl); + } + } + return prev.filter((attachment) => !sentFiles.has(attachment.file)); + }); + }, + onFailed: () => { + // Do not overwrite text the user entered while the failed request was in flight. + setMessageInput((current) => current || trimmed); + }, + }); + } catch { + setMessageInput(trimmed); + } }, [ messageInput, pendingAttachments, @@ -1907,12 +1943,32 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout roomSendInFlightRef.current = true; const previousInput = messageInput; - clearComposerState(); + const sentFiles = new Set(files); + // Clear only the text optimistically. Keeping staged attachments until upload succeeds lets a + // rejected room send be retried without silently losing its photo or file. + setMessageInput(""); try { await rooms.sendRoomMessage(trimmed, { files }); + setPendingAttachments((prev) => { + for (const attachment of prev) { + if (sentFiles.has(attachment.file) && attachment.previewUrl) { + URL.revokeObjectURL(attachment.previewUrl); + } + } + return prev.filter((attachment) => !sentFiles.has(attachment.file)); + }); } catch (error) { if (error instanceof RoomMessageDeliveredButReplyFailedError) { + // The server accepted this turn, so release only the attachments that were dispatched. + setPendingAttachments((prev) => { + for (const attachment of prev) { + if (sentFiles.has(attachment.file) && attachment.previewUrl) { + URL.revokeObjectURL(attachment.previewUrl); + } + } + return prev.filter((attachment) => !sentFiles.has(attachment.file)); + }); const message = error.message.trim() ? error.message : t("chat.messageSentButReplyFailed", "Message sent, but assistant reply failed"); @@ -2845,7 +2901,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout ref={fileInputRef} type="file" data-testid="chat-file-input" - accept="image/*,.txt,.json,.yaml,.yml,.log,.csv,.xml,.md" + accept={CHAT_ATTACHMENT_ACCEPT} multiple style={{ display: "none" }} onChange={(event) => { @@ -3830,7 +3886,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout { diff --git a/packages/dashboard/app/components/InlineCreateCard.tsx b/packages/dashboard/app/components/InlineCreateCard.tsx index 153dfd6990..03c83f994d 100644 --- a/packages/dashboard/app/components/InlineCreateCard.tsx +++ b/packages/dashboard/app/components/InlineCreateCard.tsx @@ -17,7 +17,7 @@ import { applyPresetToSelection } from "../utils/modelPresets"; import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage"; import { WorkflowSelector } from "./WorkflowSelector"; import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown"; -import { PendingImagePreviews } from "./PendingImagePreviews"; +import { PendingAttachmentPreviews } from "./PendingAttachmentPreviews"; const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; const STORAGE_KEY = "kb-inline-create-text"; @@ -890,8 +890,8 @@ export function InlineCreateCard({ /> )} - void; disabled?: boolean; removeLabel: string; @@ -16,20 +17,22 @@ interface PendingImagePreviewsProps { } /* -FNXC:QuickAddAttachments 2026-07-16-00:00: -QuickEntryBox, TaskForm, and InlineCreateCard must expose identical pending-image open and remove controls. Keeping the floating preview here prevents keyboard dismissal, focus restoration, and blob-URL removal behavior from drifting between task-creation surfaces. +FNXC:QuickAddAttachments 2026-08-03-00:00: +Task creation surfaces share one pending-attachment renderer so photos retain the established +floating preview while non-image task-store attachments have only an actionable filename and +remove control. Never render an image-open button without a preview URL. */ -export function PendingImagePreviews({ - images, +export function PendingAttachmentPreviews({ + attachments, onRemove, disabled = false, removeLabel, testIdPrefix, -}: PendingImagePreviewsProps) { +}: PendingAttachmentPreviewsProps) { const [selectedPreviewUrl, setSelectedPreviewUrl] = useState(null); const returnFocusRef = useRef(null); const selectedImage = selectedPreviewUrl - ? images.find((image) => image.previewUrl === selectedPreviewUrl) ?? null + ? attachments.find((attachment) => attachment.previewUrl === selectedPreviewUrl) ?? null : null; const closePreview = useCallback((restoreFocus = true) => { @@ -60,39 +63,48 @@ export function PendingImagePreviews({ setSelectedPreviewUrl(previewUrl); }, []); - const handleRemove = useCallback((index: number, previewUrl: string) => { - if (selectedPreviewUrl === previewUrl) { + const handleRemove = useCallback((index: number, previewUrl?: string) => { + if (previewUrl && selectedPreviewUrl === previewUrl) { closePreview(false); } onRemove(index); }, [closePreview, onRemove, selectedPreviewUrl]); - if (images.length === 0) return null; + if (attachments.length === 0) return null; return ( <>
- {images.map((image, index) => ( -
- + {attachments.map((attachment, index) => ( +
+ {attachment.previewUrl ? ( + + ) : ( + + {attachment.file.name} + + )}
))}
- {selectedImage && ( + {selectedImage?.previewUrl && ( (null); const justResetRef = useRef(false); const previousProjectIdRef = useRef(projectId); - const [pendingImages, setPendingImages] = useState([]); - const pendingImagesRef = useRef([]); + const [pendingAttachments, setPendingAttachments] = useState([]); + const pendingAttachmentsRef = useRef([]); const [isFileDragOver, setIsFileDragOver] = useState(false); const dragDepthRef = useRef(0); @@ -484,6 +500,17 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai previousProjectIdRef.current = projectId; setAgents([]); setAgentsProjectId(undefined); + /* + FNXC:QuickAddAttachments 2026-08-03-00:00: + Pending files belong to the project where they were selected. Clear them on a project switch so + an attachment cannot be uploaded into the next project's newly created task, and release image URLs. + */ + setPendingAttachments((pending) => { + for (const attachment of pending) { + if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl); + } + return []; + }); setSelectedAgentId(null); setShowAgentPicker(false); setAgentPickerPosition(null); @@ -497,13 +524,13 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai }, []); useEffect(() => { - pendingImagesRef.current = pendingImages; - }, [pendingImages]); + pendingAttachmentsRef.current = pendingAttachments; + }, [pendingAttachments]); - // Cleanup image preview URLs on unmount + // Clean up image preview URLs on unmount useEffect(() => { return () => { - pendingImagesRef.current.forEach((img) => URL.revokeObjectURL(img.previewUrl)); + pendingAttachmentsRef.current.forEach((attachment) => { if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl); }); }; }, []); @@ -628,8 +655,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai }, [showWorkflowPicker]); const resetForm = useCallback(() => { - pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); - setPendingImages([]); + pendingAttachments.forEach((attachment) => { if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl); }); + setPendingAttachments([]); dragDepthRef.current = 0; setIsFileDragOver(false); if (fileInputRef.current) { @@ -676,21 +703,22 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai if (typeof window !== "undefined") { removeScopedItem(STORAGE_KEY, projectId); } - }, [pendingImages, projectId, optionalSteps]); + }, [pendingAttachments, projectId, optionalSteps]); - const handleImageFiles = useCallback((files: FileList | File[] | null | undefined) => { + const handleAttachmentFiles = useCallback((files: FileList | File[] | null | undefined) => { if (!files || files.length === 0) return; - const newImages: PendingImage[] = []; - for (let i = 0; i < files.length; i++) { - const file = files[i]; - if (ALLOWED_IMAGE_TYPES.includes(file.type)) { - newImages.push({ file, previewUrl: URL.createObjectURL(file) }); - } + const newAttachments: PendingAttachment[] = []; + for (const file of Array.from(files)) { + if (!ALLOWED_TASK_ATTACHMENT_TYPES.has(file.type)) continue; + newAttachments.push({ + file, + previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined, + }); } - if (newImages.length > 0) { - setPendingImages((prev) => [...prev, ...newImages]); + if (newAttachments.length > 0) { + setPendingAttachments((prev) => [...prev, ...newAttachments]); } }, []); @@ -700,12 +728,13 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai }, []); /* - FNXC:QuickAddAttachments 2026-06-30-00:00: - Quick Add uses an icon-only paperclip to keep the action row compact, so the accessible name and title carry the Attach action plus pending-image count. The same image intake path handles file input, paste, and file drag/drop so previews and post-create uploads stay consistent. + FNXC:QuickAddAttachments 2026-08-03-00:00: + Quick Add must accept exactly the task-store attachment MIME set through picker, paste, and drop. + Only images receive object URLs and preview controls; all other accepted files remain uploadable file chips. */ - const attachLabel = pendingImages.length > 0 - ? t("tasks.attachImagesCount", "Attach images ({{count}} pending)", { count: pendingImages.length }) - : t("tasks.attachImages", "Attach images"); + const attachLabel = pendingAttachments.length > 0 + ? t("tasks.attachFilesCount", "Attach photos or files ({{count}} pending)", { count: pendingAttachments.length }) + : t("tasks.attachFiles", "Attach photos or files"); const handleDragEnter = useCallback((e: React.DragEvent) => { if (!isFileDrag(e.dataTransfer)) return; @@ -740,19 +769,19 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai e.preventDefault(); clearFileDragState(); if (isSubmitting) return; - handleImageFiles(e.dataTransfer.files); - }, [clearFileDragState, handleImageFiles, isFileDrag, isSubmitting]); + handleAttachmentFiles(e.dataTransfer.files); + }, [clearFileDragState, handleAttachmentFiles, isFileDrag, isSubmitting]); const handlePaste = useCallback((e: React.ClipboardEvent) => { if (isSubmitting) return; - handleImageFiles(e.clipboardData?.files); - }, [handleImageFiles, isSubmitting]); + handleAttachmentFiles(e.clipboardData?.files); + }, [handleAttachmentFiles, isSubmitting]); - const removeImage = useCallback((index: number) => { - setPendingImages((prev) => { + const removeAttachment = useCallback((index: number) => { + setPendingAttachments((prev) => { const removed = prev[index]; if (removed) { - URL.revokeObjectURL(removed.previewUrl); + if (removed.previewUrl) URL.revokeObjectURL(removed.previewUrl); } return prev.filter((_, i) => i !== index); }); @@ -823,13 +852,13 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai } } } - if (createdTask && typeof createdTask.id === "string" && createdTask.id.trim() && pendingImages.length > 0) { + if (createdTask && typeof createdTask.id === "string" && createdTask.id.trim() && pendingAttachments.length > 0) { const failures: string[] = []; - for (const pendingImage of pendingImages) { + for (const pendingAttachment of pendingAttachments) { try { - await uploadAttachment(createdTask.id, pendingImage.file, projectId); + await uploadAttachment(createdTask.id, pendingAttachment.file, projectId); } catch { - failures.push(pendingImage.file.name); + failures.push(pendingAttachment.file.name); } } @@ -871,7 +900,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai sessionAdvisorOverride, priority, effectiveNodeId, - pendingImages, + pendingAttachments, projectId, addToast, resetForm, @@ -1821,7 +1850,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai {isFileDragOver && ( )}
@@ -2336,8 +2365,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai title={attachLabel} >
)} - {isModelMenuOpen && portalRoot && modelMenuPosition && createPortal( @@ -2643,11 +2672,11 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai { - handleImageFiles(e.target.files); + handleAttachmentFiles(e.target.files); e.currentTarget.value = ""; }} data-testid="quick-entry-file-input" diff --git a/packages/dashboard/app/components/TaskForm.tsx b/packages/dashboard/app/components/TaskForm.tsx index a1a5267461..b01f17b00a 100644 --- a/packages/dashboard/app/components/TaskForm.tsx +++ b/packages/dashboard/app/components/TaskForm.tsx @@ -13,7 +13,7 @@ import { REPO_OVERRIDE_RE, resolveEffectiveGithubRepoDefault } from "./githubTra import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils/priorityIndicator"; import { ProviderIcon } from "./ProviderIcon"; import { WorkflowIcon } from "./WorkflowIcon"; -import { PendingImagePreviews } from "./PendingImagePreviews"; +import { PendingAttachmentPreviews } from "./PendingAttachmentPreviews"; function getNodeStatusLabel(status: NodeInfo["status"], t: (key: string, defaultValue: string) => string): string { if (status === "online") return t("taskForm.nodeStatusOnline", "Online"); @@ -1201,8 +1201,8 @@ export function TaskForm({ {/* Attachments */}
- { expect(clickSpy).toHaveBeenCalled(); }); - it("allows attaching an image and sends with attachments only", async () => { - const sendMessage = vi.fn(); + it("allows attaching an image and clears it after attachment-only delivery", async () => { + const sendMessage = vi.fn((_content: string, _files?: File[], callbacks?: { onDelivered?: () => void }) => { + callbacks?.onDelivered?.(); + }); setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage }); await renderWithAct(); @@ -168,8 +170,29 @@ describe("ChatView core interactions", () => { expect(sendButton).not.toBeDisabled(); await userEvent.click(sendButton); - expect(sendMessage).toHaveBeenCalledWith("", [imageFile]); - expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument(); + expect(sendMessage).toHaveBeenCalledWith("", [imageFile], expect.objectContaining({ + onDelivered: expect.any(Function), + onFailed: expect.any(Function), + })); + await waitFor(() => { + expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument(); + }); + }); + + it("retains direct-chat attachments after a failed upload for retry", async () => { + const sendMessage = vi.fn((_content: string, _files?: File[], callbacks?: { onFailed?: () => void }) => { + callbacks?.onFailed?.(); + }); + setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage }); + await renderWithAct(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const textFile = new File(["retry"], "retry.txt", { type: "text/plain" }); + fireEvent.change(fileInput, { target: { files: [textFile] } }); + await userEvent.click(screen.getByTestId("chat-send-btn")); + + expect(sendMessage).toHaveBeenCalledWith("", [textFile], expect.any(Object)); + expect(await screen.findByText("retry.txt")).toBeInTheDocument(); }); it("accepts non-image files and renders filename preview", async () => { @@ -184,26 +207,46 @@ describe("ChatView core interactions", () => { expect(mockCreateObjectURL).not.toHaveBeenCalled(); }); - it("adds image attachments from paste events", async () => { + it("accepts the task attachment MIME set and rejects server-unsupported logs", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + await renderWithAct(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const video = new File(["video"], "demo.mp4", { type: "video/mp4" }); + const toml = new File(["key = 'value'"], "config.toml", { type: "text/x-toml" }); + const unsupportedLog = new File(["log"], "server.log", { type: "text/x-log" }); + fireEvent.change(fileInput, { target: { files: [video, toml, unsupportedLog] } }); + + expect(fileInput).toHaveAttribute("accept", expect.stringContaining("video/mp4")); + expect(fileInput).toHaveAttribute("accept", expect.stringContaining(".toml")); + expect(await screen.findByText("demo.mp4")).toBeInTheDocument(); + expect(screen.getByText("config.toml")).toBeInTheDocument(); + expect(screen.queryByText("server.log")).not.toBeInTheDocument(); + }); + + it("accepts mixed image and supported text attachments from paste events", async () => { setupMockChat({ activeSession: activeSessionFixture, messages: [] }); await renderWithAct(); const textarea = screen.getByTestId("chat-input"); const imageFile = new File(["image"], "paste.png", { type: "image/png" }); - fireEvent.paste(textarea, { clipboardData: { files: [imageFile] } }); + const textFile = new File(["note"], "paste.txt", { type: "text/plain" }); + fireEvent.paste(textarea, { clipboardData: { files: [imageFile, textFile] } }); expect(await screen.findByTestId("chat-attachment-previews")).toBeInTheDocument(); + expect(screen.getByTestId("chat-attachment-preview-0").querySelector("img")).toBeTruthy(); + expect(screen.getByText("paste.txt")).toBeInTheDocument(); }); - it("adds attachments from drag-and-drop", async () => { + it("adds supported text attachments from drag-and-drop", async () => { setupMockChat({ activeSession: activeSessionFixture, messages: [] }); await renderWithAct(); const wrapper = document.querySelector(".chat-input-wrapper") as HTMLElement; - const textFile = new File(["log"], "drop.log", { type: "text/x-log" }); + const textFile = new File(["log"], "drop.txt", { type: "text/plain" }); fireEvent.drop(wrapper, { dataTransfer: { files: [textFile] } }); - expect(await screen.findByText("drop.log")).toBeInTheDocument(); + expect(await screen.findByText("drop.txt")).toBeInTheDocument(); }); it("removes pending attachments and revokes preview urls", async () => { diff --git a/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx index 9b7ed4a42c..9c1610c824 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { ChatView } from "../ChatView"; import { loadAllAppCss } from "../../test/cssFixture"; import * as useChatModule from "../../hooks/useChat"; @@ -200,6 +200,24 @@ describe("FN-5997 mobile chat message pane rendering", () => { setupRooms(); }); + it("keeps the direct mobile composer file-capable for a supported text attachment", async () => { + const restoreMatchMedia = mockViewportMode("mobile"); + try { + setupChat({ sessions: [activeSession], filteredSessions: [activeSession], activeSession }); + await renderWithCss(); + + const attachButton = screen.getByTestId("chat-attach-btn"); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [new File(["note"], "mobile.txt", { type: "text/plain" })] } }); + + expect(attachButton).toBeInTheDocument(); + expect(screen.getByText("mobile.txt")).toBeInTheDocument(); + expect(screen.getByTestId("chat-attachment-remove-0")).toHaveAccessibleName("Remove mobile.txt"); + } finally { + restoreMatchMedia.mockRestore(); + } + }); + it("lets the direct-thread mobile empty states span the message pane without mobile card chrome while preserving other states", async () => { const restoreMatchMedia = mockViewportMode("mobile"); try { diff --git a/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx index 22b63db639..e9906308e7 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx @@ -553,6 +553,23 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { expect(addToast).toHaveBeenCalledWith("POST failed", "error"); }); + it("preserves staged room attachments when the upload request fails", async () => { + const sendRoomMessage = vi.fn().mockRejectedValueOnce(new Error("POST failed")); + setup({}, { sendRoomMessage, activeRoom: roomA }); + + await renderWithAct(); + + const file = new File(["note"], "retry.txt", { type: "text/plain" }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [file] } }); + const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; + await userEvent.type(textarea, "Retry attachment{enter}"); + + await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledWith("Retry attachment", { files: [file] })); + await waitFor(() => expect(textarea.value).toBe("Retry attachment")); + expect(screen.getByText("retry.txt")).toBeInTheDocument(); + }); + it("clears room composer on Enter when room send succeeds", async () => { const sendRoomMessage = vi.fn().mockResolvedValue(undefined); setup({}, { sendRoomMessage, activeRoom: roomA }); @@ -857,7 +874,10 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { }); await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(1)); - expect(sendMessage).toHaveBeenCalledWith("Direct second", []); + expect(sendMessage).toHaveBeenCalledWith("Direct second", [], expect.objectContaining({ + onDelivered: expect.any(Function), + onFailed: expect.any(Function), + })); expect(sendRoomMessage).toHaveBeenCalledTimes(1); mediaSpy.mockRestore(); }); @@ -1232,7 +1252,10 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const textarea = screen.getByTestId("chat-input"); await userEvent.type(textarea, "Direct hello{enter}"); - expect(sendMessage).toHaveBeenCalledWith("Direct hello", []); + expect(sendMessage).toHaveBeenCalledWith("Direct hello", [], expect.objectContaining({ + onDelivered: expect.any(Function), + onFailed: expect.any(Function), + })); expect(sendRoomMessage).not.toHaveBeenCalled(); expect(addToast).not.toHaveBeenCalled(); }); diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx index 6dca6c5759..a4d277d394 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -3450,15 +3450,15 @@ describe("QuickEntryBox", () => { }); }); - describe("image attachments", () => { + describe("task attachments", () => { it("shows an icon-only Attach control when expanded", () => { renderQuickEntryBox({}); expandQuickEntry(); const attachButton = screen.getByTestId("quick-entry-attach"); expect(attachButton).toBeInTheDocument(); - expect(attachButton).toHaveAccessibleName("Attach images"); - expect(attachButton).toHaveAttribute("title", "Attach images"); + expect(attachButton).toHaveAccessibleName("Attach photos or files"); + expect(attachButton).toHaveAttribute("title", "Attach photos or files"); expect(attachButton.textContent).not.toContain("Attach"); expect(attachButton.querySelector("svg")).toBeTruthy(); expect(attachButton.classList.contains("btn-icon")).toBe(true); @@ -3476,7 +3476,7 @@ describe("QuickEntryBox", () => { expect(clickSpy).toHaveBeenCalled(); }); - it("adds a preview when an image is pasted", () => { + it("adds an image preview when an image is pasted", () => { renderQuickEntryBox({}); expandQuickEntry(); @@ -3552,8 +3552,8 @@ describe("QuickEntryBox", () => { fireEvent.change(fileInput, { target: { files: [file] } }); const attachButton = screen.getByTestId("quick-entry-attach"); - expect(attachButton).toHaveAccessibleName("Attach images (1 pending)"); - expect(attachButton).toHaveAttribute("title", "Attach images (1 pending)"); + expect(attachButton).toHaveAccessibleName("Attach photos or files (1 pending)"); + expect(attachButton).toHaveAttribute("title", "Attach photos or files (1 pending)"); expect(attachButton.textContent).toBe("1"); expect(attachButton.textContent).not.toContain("Attach"); }); @@ -3581,7 +3581,7 @@ describe("QuickEntryBox", () => { expect(screen.queryByTestId("quick-entry-drop-target")).toBeNull(); fireEvent.dragEnter(box, { dataTransfer: { types: ["Files"], files: [] } }); - expect(screen.getByTestId("quick-entry-drop-target")).toHaveTextContent("Drop images to attach"); + expect(screen.getByTestId("quick-entry-drop-target")).toHaveTextContent("Drop photos or files to attach"); expect(box.classList.contains("quick-entry-box--drag-over")).toBe(true); fireEvent.dragLeave(box, { dataTransfer: { types: ["Files"], files: [] } }); @@ -3605,7 +3605,7 @@ describe("QuickEntryBox", () => { expect(screen.getByTestId("quick-entry-drop-target")).toBeInTheDocument(); }); - it("adds previews for supported dropped images and rejects unsupported dropped files", () => { + it("accepts mixed supported drops as an image preview and accessible file chip", () => { renderQuickEntryBox({}); const box = screen.getByTestId("quick-entry-box"); const image = new File(["image"], "dropped.png", { type: "image/png" }); @@ -3616,7 +3616,48 @@ describe("QuickEntryBox", () => { expect(screen.queryByTestId("quick-entry-drop-target")).toBeNull(); expect(screen.getByRole("button", { name: "Open image dropped.png" })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Open image notes.txt" })).toBeNull(); + expect(screen.getByTestId("quick-entry-preview-file-1")).toHaveTextContent("notes.txt"); + expect(screen.queryByTestId("quick-entry-preview-open-1")).toBeNull(); + }); + + it("accepts supported picker and paste files, preserves duplicate names, and ignores unsupported MIME types", () => { + renderQuickEntryBox({}); + const fileInput = screen.getByTestId("quick-entry-file-input") as HTMLInputElement; + const textarea = screen.getByTestId("quick-entry-input"); + const firstJson = new File(["{}"], "data.json", { type: "application/json" }); + const duplicateJson = new File(["{}"], "data.json", { type: "application/json" }); + const unsupported = new File(["binary"], "archive.bin", { type: "application/octet-stream" }); + + expect(fileInput).toHaveAttribute("accept", expect.stringContaining("video/mp4")); + fireEvent.change(fileInput, { target: { files: [firstJson, unsupported] } }); + fireEvent.paste(textarea, { clipboardData: { files: [duplicateJson] } }); + fireEvent.change(fileInput, { target: { files: [] } }); + + expect(screen.getByTestId("quick-entry-preview-file-0")).toHaveTextContent("data.json"); + expect(screen.getByTestId("quick-entry-preview-file-1")).toHaveTextContent("data.json"); + expect(screen.queryByText("archive.bin")).toBeNull(); + expect(screen.getByTestId("quick-entry-attach")).toHaveAccessibleName("Attach photos or files (2 pending)"); + }); + + it("uploads supported mixed attachments sequentially and reports partial failures after creation", async () => { + const onCreate = vi.fn().mockResolvedValue(CREATED_TASK); + const addToast = vi.fn(); + const photo = new File(["photo"], "photo.png", { type: "image/png" }); + const note = new File(["note"], "note.txt", { type: "text/plain" }); + vi.mocked(uploadAttachment) + .mockResolvedValueOnce({} as any) + .mockRejectedValueOnce(new Error("upload failed")); + renderQuickEntryBox({ onCreate, addToast }); + const textarea = screen.getByTestId("quick-entry-input"); + const fileInput = screen.getByTestId("quick-entry-file-input"); + + fireEvent.change(fileInput, { target: { files: [photo, note] } }); + fireEvent.change(textarea, { target: { value: "Create with mixed attachments" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + await waitFor(() => expect(uploadAttachment).toHaveBeenCalledTimes(2)); + expect(uploadAttachment.mock.calls.map((call) => call[1])).toEqual([photo, note]); + expect(addToast).toHaveBeenCalledWith("Failed to upload: note.txt", "error"); }); it("uploads images added by dropping files after task creation", async () => { diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index cab4847d99..1ff504393f 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -136,8 +136,15 @@ export interface UseChatReturn { deleteSession: (id: string) => Promise; // Message operations - /** Send a message, optionally with file attachments to upload with the prompt. */ - sendMessage: (content: string, attachments?: File[]) => void; + /** + * Send a message, optionally with file attachments to upload with the prompt. Attachment + * callbacks distinguish a rejected upload from a server-accepted turn whose reply later fails. + */ + sendMessage: ( + content: string, + attachments?: File[], + callbacks?: { onDelivered?: () => void; onFailed?: () => void }, + ) => void; /** * FNXC:ChatMessageEdit 2026-07-07-09:00: * Edit an earlier user message: truncates local + persisted history from that message onward @@ -1263,7 +1270,11 @@ export function useChat( * @param content Message text content to send. * @param attachments Optional files to upload with the message in the same request. */ - const sendMessageRef = useRef<(content: string, attachments?: File[]) => void>(() => { + const sendMessageRef = useRef<( + content: string, + attachments?: File[], + callbacks?: { onDelivered?: () => void; onFailed?: () => void }, + ) => void>(() => { // no-op until sendMessage is defined }); const visibilitySuspension = useTabVisibilitySuspension(); @@ -1306,8 +1317,15 @@ export function useChat( }, [attachIfGenerating, loadMessages, projectId, refreshSessions]); const sendMessage = useCallback( - (content: string, attachments?: File[]) => { - if (!activeSession) return; + ( + content: string, + attachments?: File[], + callbacks?: { onDelivered?: () => void; onFailed?: () => void }, + ) => { + if (!activeSession) { + callbacks?.onFailed?.(); + return; + } if (isStreamingRef.current) { const trimmedContent = content.trim(); @@ -1396,6 +1414,7 @@ export function useChat( isStreamingRef.current = false; streamRef.current = null; lastAttachedGenerationRef.current = null; + callbacks?.onDelivered?.(); // Clean up tracked ID after a short delay (SSE event should arrive quickly) setTimeout(() => { @@ -1412,6 +1431,18 @@ export function useChat( const shouldSuppressSuspensionError = isLikelyTabSuspensionError(suspensionMessage); const acceptedByServer = meta?.requestAccepted === true; + /* + FNXC:ChatAttachments 2026-08-03-00:00: + A direct composer owns its staged File objects and preview URLs until the server accepts + the multipart turn. Tell it to retain those files on pre-delivery/upload failure, but + release them after an accepted turn even when the provider cannot produce a reply. + */ + if (acceptedByServer) { + callbacks?.onDelivered?.(); + } else { + callbacks?.onFailed?.(); + } + /* FNXC:ChatReliability 2026-07-01-00:00: Provider errors can arrive after ChatManager has already persisted and sent the user's turn to the model context. Keep the visible user bubble for accepted streams and reconcile it with the persisted transcript instead of rolling it back like a pre-delivery HTTP validation failure. diff --git a/packages/dashboard/src/__tests__/chat-attachment-content.test.ts b/packages/dashboard/src/__tests__/chat-attachment-content.test.ts index a9527b2c41..267e94d717 100644 --- a/packages/dashboard/src/__tests__/chat-attachment-content.test.ts +++ b/packages/dashboard/src/__tests__/chat-attachment-content.test.ts @@ -54,6 +54,23 @@ describe("readChatAttachmentContents", () => { expect(formatChatAttachmentContents(result.attachmentContents)).toContain("hello from attachment"); }); + it("inlines TOML while retaining videos as non-inlineable attachments", async () => { + const root = await makeRoot(); + await mkdir(join(root, ".fusion", "chat-attachments", "session-1"), { recursive: true }); + await writeFile(join(root, ".fusion", "chat-attachments", "session-1", "config.toml"), "enabled = true"); + await writeFile(join(root, ".fusion", "chat-attachments", "session-1", "demo.mp4"), Buffer.from("video")); + + const result = await readChatAttachmentContents(root, { kind: "session", sessionId: "session-1" }, [ + attachment({ filename: "config.toml", originalName: "config.toml", mimeType: "text/x-toml" }), + attachment({ filename: "demo.mp4", originalName: "demo.mp4", mimeType: "video/mp4" }), + ]); + + expect(result.attachmentContents).toEqual([ + { originalName: "config.toml", mimeType: "text/x-toml", text: "enabled = true" }, + ]); + expect(formatChatAttachmentContents(result.attachmentContents)).toContain("```toml"); + }); + it("converts matching image attachments to base64 content blocks", async () => { const root = await makeRoot(); await mkdir(join(root, ".fusion", "chat-attachments", "session-1"), { recursive: true }); diff --git a/packages/dashboard/src/chat-attachment-content.ts b/packages/dashboard/src/chat-attachment-content.ts index 9504cbbea3..ed2d527643 100644 --- a/packages/dashboard/src/chat-attachment-content.ts +++ b/packages/dashboard/src/chat-attachment-content.ts @@ -46,7 +46,9 @@ const IMAGE_MIME_TYPES = new Set([ ]); const TEXT_MIME_TYPES = new Set( - [...CHAT_ALLOWED_MIME_TYPES].filter((mimeType) => !IMAGE_MIME_TYPES.has(mimeType)), + [...CHAT_ALLOWED_MIME_TYPES].filter( + (mimeType) => !IMAGE_MIME_TYPES.has(mimeType) && !mimeType.startsWith("video/"), + ), ); export const CHAT_TEXT_INLINE_LIMIT = 50 * 1024; diff --git a/packages/dashboard/src/routes/chat-attachment-config.ts b/packages/dashboard/src/routes/chat-attachment-config.ts index af94b6a123..709b44fbac 100644 --- a/packages/dashboard/src/routes/chat-attachment-config.ts +++ b/packages/dashboard/src/routes/chat-attachment-config.ts @@ -1,9 +1,18 @@ +/* +FNXC:ChatAttachments 2026-08-03-00:00: +Chat upload validation must match task attachments: the composer now allows the full task-store MIME +set, so direct and room routes must persist videos, Markdown, and TOML instead of rejecting them. +*/ export const CHAT_ALLOWED_MIME_TYPES = new Set([ "image/png", "image/jpeg", "image/gif", "image/webp", + "video/mp4", + "video/webm", + "video/quicktime", "text/plain", + "text/markdown", "application/json", "text/yaml", "text/x-toml", @@ -12,3 +21,10 @@ export const CHAT_ALLOWED_MIME_TYPES = new Set([ ]); export const CHAT_MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024; +export const CHAT_MAX_VIDEO_ATTACHMENT_SIZE = 100 * 1024 * 1024; + +export function getChatAttachmentMaxSize(mimeType: string): number { + return mimeType.startsWith("video/") + ? CHAT_MAX_VIDEO_ATTACHMENT_SIZE + : CHAT_MAX_ATTACHMENT_SIZE; +} diff --git a/packages/dashboard/src/routes/register-chat-room-routes.ts b/packages/dashboard/src/routes/register-chat-room-routes.ts index 04de5b8dc5..7506f6d584 100644 --- a/packages/dashboard/src/routes/register-chat-room-routes.ts +++ b/packages/dashboard/src/routes/register-chat-room-routes.ts @@ -8,7 +8,7 @@ import { RoomReplyGenerationError } from "../chat.js"; import { createProjectScopedChatManager, resolveProjectChatContext } from "../chat-project-services.js"; import { ApiError, badRequest, internalError, notFound } from "../api-error.js"; import { rateLimit, RATE_LIMITS } from "../rate-limit.js"; -import { CHAT_ALLOWED_MIME_TYPES, CHAT_MAX_ATTACHMENT_SIZE } from "./chat-attachment-config.js"; +import { CHAT_ALLOWED_MIME_TYPES, CHAT_MAX_VIDEO_ATTACHMENT_SIZE, getChatAttachmentMaxSize } from "./chat-attachment-config.js"; import type { ApiRoutesContext } from "./types.js"; function isSlugCollisionError(err: unknown): boolean { @@ -53,7 +53,7 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext, deps: ChatRoomRout } const multerError = err as { code?: string }; if (multerError?.code === "LIMIT_FILE_SIZE") { - next(badRequest(`File too large. Maximum: ${CHAT_MAX_ATTACHMENT_SIZE} bytes (5MB)`)); + next(badRequest(`File too large. Maximum: ${CHAT_MAX_VIDEO_ATTACHMENT_SIZE} bytes (100MB)`)); return; } next(err as Error); @@ -415,8 +415,9 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext, deps: ChatRoomRout const file = req.file; if (!file) throw badRequest("file is required"); if (!CHAT_ALLOWED_MIME_TYPES.has(file.mimetype)) throw badRequest(`Invalid mime type '${file.mimetype}'`); - if (file.size > CHAT_MAX_ATTACHMENT_SIZE) { - throw badRequest(`File too large (${file.size} bytes). Maximum: ${CHAT_MAX_ATTACHMENT_SIZE} bytes (5MB)`); + const maxSize = getChatAttachmentMaxSize(file.mimetype); + if (file.size > maxSize) { + throw badRequest(`File too large (${file.size} bytes). Maximum: ${maxSize} bytes (${file.mimetype.startsWith("video/") ? "100MB" : "5MB"})`); } const { store: scopedStore } = await getProjectContext(req); const roomDir = resolve(scopedStore.getRootDir(), ".fusion", "chat-room-attachments", roomId); diff --git a/packages/dashboard/src/routes/register-chat-routes.ts b/packages/dashboard/src/routes/register-chat-routes.ts index 0c808c2b35..1a474e780c 100644 --- a/packages/dashboard/src/routes/register-chat-routes.ts +++ b/packages/dashboard/src/routes/register-chat-routes.ts @@ -12,7 +12,7 @@ the store-alignment fix and failed lint as unused; keep manager construction on store/chatStore pair only. */ import { getOrCreateScopedChatManager, resolveProjectChatContext } from "../chat-project-services.js"; -import { CHAT_ALLOWED_MIME_TYPES, CHAT_MAX_ATTACHMENT_SIZE } from "./chat-attachment-config.js"; +import { CHAT_ALLOWED_MIME_TYPES, CHAT_MAX_VIDEO_ATTACHMENT_SIZE, getChatAttachmentMaxSize } from "./chat-attachment-config.js"; import { rateLimit, RATE_LIMITS } from "../rate-limit.js"; import { writeSSEEvent, type SessionBufferedEvent } from "../sse-buffer.js"; import { TASK_PLANNER_CHAT_AGENT_ID_PREFIX } from "../chat.js"; @@ -49,7 +49,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): } const multerError = err as { code?: string; message?: string }; if (multerError?.code === "LIMIT_FILE_SIZE") { - next(badRequest(`File too large. Maximum: ${CHAT_MAX_ATTACHMENT_SIZE} bytes (5MB)`)); + next(badRequest(`File too large. Maximum: ${CHAT_MAX_VIDEO_ATTACHMENT_SIZE} bytes (100MB)`)); return; } next(err as Error); @@ -64,7 +64,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): } const multerError = err as { code?: string; message?: string }; if (multerError?.code === "LIMIT_FILE_SIZE") { - next(badRequest(`File too large. Maximum: ${CHAT_MAX_ATTACHMENT_SIZE} bytes (5MB)`)); + next(badRequest(`File too large. Maximum: ${CHAT_MAX_VIDEO_ATTACHMENT_SIZE} bytes (100MB)`)); return; } next(err as Error); @@ -80,8 +80,9 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): throw badRequest(`Invalid mime type '${file.mimetype}'`); } - if (file.size > CHAT_MAX_ATTACHMENT_SIZE) { - throw badRequest(`File too large (${file.size} bytes). Maximum: ${CHAT_MAX_ATTACHMENT_SIZE} bytes (5MB)`); + const maxSize = getChatAttachmentMaxSize(file.mimetype); + if (file.size > maxSize) { + throw badRequest(`File too large (${file.size} bytes). Maximum: ${maxSize} bytes (${file.mimetype.startsWith("video/") ? "100MB" : "5MB"})`); } const sessionDir = resolve(rootDir, ".fusion", "chat-attachments", sessionId);