FN-6504: allow attachment-only chat sends
Allow chat composers and API routes to deliver attachment-only messages without placeholder text. - Permit main chat sends when staged attachments exist even if the textarea is blank. - Accept upload-backed and referenced attachment-only payloads in chat and room message routes while still rejecting fully empty messages. - Add UI/API regression coverage, documentation, and a patch changeset for attachment-only chat sends. Files changed: .changeset/fn-6504-attachment-only-send.md | 5 ++ docs/dashboard-guide.md | 1 + packages/dashboard/app/components/ChatView.tsx | 72 ++++++++++++++++++++-- .../app/components/__tests__/ChatView.test.tsx | 39 ++++++++++++ .../src/__tests__/chat-attachment-routes.test.ts | 24 +++++++- .../src/__tests__/chat-routes.rooms.test.ts | 62 +++++++++++++------ .../src/routes/register-chat-room-routes.ts | 13 +++- .../dashboard/src/routes/register-chat-routes.ts | 21 ++++--- 8 files changed, 205 insertions(+), 32 deletions(-) Fusion-Task-Id: FN-6504 Fusion-Task-Lineage: f64f7874-4e32-4ae5-a5b1-d8c4b78e23dd
This commit is contained in:
5
.changeset/fn-6504-attachment-only-send.md
Normal file
5
.changeset/fn-6504-attachment-only-send.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Allow chat attachments to be sent without accompanying text in Quick Chat and Main Chat while still rejecting fully empty sends.
|
||||
@@ -251,6 +251,7 @@ Chat view provides project-scoped conversations with agents.
|
||||
- The desktop Chat view toggle and mobile Chat tab now show an unread-response indicator when a live assistant reply arrives for your active chat thread after you leave Chat; opening Chat clears it immediately.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -2025,7 +2025,12 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
|
||||
const handleSendDispatch = useCallback(async () => {
|
||||
const trimmed = messageInput.trim();
|
||||
if (!trimmed) {
|
||||
const files = pendingAttachments.map((attachment) => attachment.file);
|
||||
/**
|
||||
* FNXC:Chat 2026-06-17-02:12:
|
||||
* Main Chat room dispatch must permit attachment-only sends. Block only a truly empty composer so staged files can reach the backend without requiring filler text.
|
||||
*/
|
||||
if (!trimmed && files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2053,7 +2058,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
clearComposerState();
|
||||
|
||||
try {
|
||||
await rooms.sendRoomMessage(trimmed, { files: pendingAttachments.map((attachment) => attachment.file) });
|
||||
await rooms.sendRoomMessage(trimmed, { files });
|
||||
} catch (error) {
|
||||
if (error instanceof RoomMessageDeliveredButReplyFailedError) {
|
||||
const message = error.message.trim()
|
||||
@@ -3635,8 +3640,66 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
|
||||
{rooms.activeRoom && (
|
||||
<div className="chat-input-area">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,.txt,.json,.yaml,.yml,.log,.csv,.xml,.md"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={(event) => {
|
||||
handleAttachmentFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{pendingAttachments.length > 0 && (
|
||||
<div className="chat-attachment-previews" data-testid="chat-attachment-previews">
|
||||
{pendingAttachments.map((attachment, index) => (
|
||||
<div
|
||||
key={attachment.previewUrl || `${attachment.file.name}-${index}`}
|
||||
className="chat-attachment-preview"
|
||||
data-testid={`chat-attachment-preview-${index}`}
|
||||
>
|
||||
{attachment.previewUrl ? (
|
||||
<img src={attachment.previewUrl} alt={attachment.file.name} />
|
||||
) : (
|
||||
<span className="chat-attachment-preview-name">{attachment.file.name}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="chat-attachment-remove"
|
||||
onClick={() => removeAttachment(index)}
|
||||
data-testid={`chat-attachment-remove-${index}`}
|
||||
aria-label={`Remove ${attachment.file.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-input-row">
|
||||
<div className="chat-input-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon chat-attach-btn"
|
||||
data-testid="chat-attach-btn"
|
||||
aria-label={t("chat.attachFiles", "Attach files")}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Paperclip size={16} />
|
||||
</button>
|
||||
<div
|
||||
className={`chat-input-wrapper${isDragOver ? " chat-input-wrapper--dragover" : ""}`}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragOver(false);
|
||||
handleAttachmentFiles(event.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={handleComposerRef}
|
||||
className="chat-input-textarea"
|
||||
@@ -3648,6 +3711,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
onClick={handleInputSelectionChange}
|
||||
onBlur={handleInputBlur}
|
||||
onFocus={handleInputFocus}
|
||||
onPaste={handlePaste}
|
||||
onTouchStart={(event) => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (window.innerWidth > 768) return;
|
||||
@@ -3693,7 +3757,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
onClick={() => {
|
||||
void handleSendDispatch();
|
||||
}}
|
||||
disabled={!messageInput.trim()}
|
||||
disabled={!messageInput.trim() && pendingAttachments.length === 0}
|
||||
data-testid="chat-send-btn"
|
||||
style={{ touchAction: "manipulation" }}
|
||||
>
|
||||
|
||||
@@ -1632,6 +1632,45 @@ describe("ChatView", () => {
|
||||
localStorage.removeItem("fusion:chat-scope");
|
||||
});
|
||||
|
||||
it("sends room attachments when the composer text is empty", async () => {
|
||||
localStorage.setItem("fusion:chat-scope", "rooms");
|
||||
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
|
||||
const sendMessage = vi.fn();
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage });
|
||||
setupMockRooms({
|
||||
activeRoom: {
|
||||
id: "room-001",
|
||||
projectId: "proj-123",
|
||||
name: "backend",
|
||||
createdAt: "2026-04-08T00:00:00.000Z",
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
},
|
||||
sendRoomMessage,
|
||||
});
|
||||
|
||||
try {
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const textFile = new File(["room"], "room.txt", { type: "text/plain" });
|
||||
fireEvent.change(fileInput, { target: { files: [textFile] } });
|
||||
|
||||
expect(await screen.findByTestId("chat-attachment-previews")).toBeInTheDocument();
|
||||
const sendButton = screen.getByTestId("chat-send-btn");
|
||||
expect(sendButton).not.toBeDisabled();
|
||||
|
||||
await userEvent.click(sendButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendRoomMessage).toHaveBeenCalledWith("", { files: [textFile] });
|
||||
});
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument();
|
||||
} finally {
|
||||
localStorage.removeItem("fusion:chat-scope");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps direct chat send behavior unchanged when chat rooms are enabled", async () => {
|
||||
localStorage.setItem("fusion:chat-scope", "direct");
|
||||
const sendMessage = vi.fn();
|
||||
|
||||
@@ -205,6 +205,14 @@ describe("chat attachment routes", () => {
|
||||
expect(mockSendMessage).toHaveBeenCalledWith(session.id, "hello", undefined, undefined, attachments, { generationId: 1 });
|
||||
});
|
||||
|
||||
it("accepts whitespace-only JSON message content when attachments are referenced", async () => {
|
||||
const attachments = [{ id: "att-1", filename: "x.txt", originalName: "x.txt", mimeType: "text/plain", size: 1, createdAt: new Date().toISOString() }];
|
||||
const body = JSON.stringify({ content: " ", attachments });
|
||||
const response = await request(app, "POST", `/api/chat/sessions/${session.id}/messages`, body, { "content-type": "application/json" });
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockSendMessage).toHaveBeenCalledWith(session.id, "", undefined, undefined, attachments, { generationId: 1 });
|
||||
});
|
||||
|
||||
it("passes multipart file attachments on message send", async () => {
|
||||
const { payload, boundary } = makeMultipartMessageRequest("hello", "x.txt", "text/plain", Buffer.from("x"));
|
||||
const response = await request(app, "POST", `/api/chat/sessions/${session.id}/messages`, payload, { "content-type": `multipart/form-data; boundary=${boundary}` }, payload);
|
||||
@@ -219,11 +227,25 @@ describe("chat attachment routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 400 for multipart message send without content", async () => {
|
||||
it("accepts multipart file-only message send without content", async () => {
|
||||
const { payload, boundary } = makeMultipartMessageRequest(undefined, "x.txt", "text/plain", Buffer.from("x"));
|
||||
const response = await request(app, "POST", `/api/chat/sessions/${session.id}/messages`, payload, { "content-type": `multipart/form-data; boundary=${boundary}` }, payload);
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockSendMessage).toHaveBeenCalledWith(
|
||||
session.id,
|
||||
"",
|
||||
undefined,
|
||||
undefined,
|
||||
[expect.objectContaining({ originalName: "x.txt", mimeType: "text/plain", size: 1 })],
|
||||
{ generationId: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 400 for empty message send without content or attachments", async () => {
|
||||
const response = await request(app, "POST", `/api/chat/sessions/${session.id}/messages`, JSON.stringify({ content: "" }), { "content-type": "application/json" });
|
||||
expect(response.status).toBe(400);
|
||||
expect((response.body as any).error).toContain("content is required");
|
||||
expect(mockSendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -160,25 +160,26 @@ describe("Chat HTTP + SSE routes — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
|
||||
it("covers room message route contracts: trim, senderAgentId rejection, before cursor, delete idempotency, attachments", async () => {
|
||||
const { createServer } = await import("../server.js");
|
||||
const sendRoomMessage = vi.fn(async (roomId: string, content: string, attachments?: any[]) => {
|
||||
const userMessage = chatStore.addRoomMessage(roomId, {
|
||||
role: "user",
|
||||
content,
|
||||
senderAgentId: null,
|
||||
mentions: ["agent-room"],
|
||||
...(Array.isArray(attachments) ? { attachments } : {}),
|
||||
});
|
||||
chatStore.addRoomMessage(roomId, {
|
||||
role: "assistant",
|
||||
content: "room reply",
|
||||
senderAgentId: "agent-room",
|
||||
mentions: ["agent-room"],
|
||||
});
|
||||
return { userMessage, responders: ["agent-room"] };
|
||||
});
|
||||
const appWithRoomReplies = createServer(store as any, {
|
||||
chatStore,
|
||||
chatManager: {
|
||||
sendRoomMessage: async (roomId: string, content: string, attachments?: any[]) => {
|
||||
const userMessage = chatStore.addRoomMessage(roomId, {
|
||||
role: "user",
|
||||
content,
|
||||
senderAgentId: null,
|
||||
mentions: ["agent-room"],
|
||||
...(Array.isArray(attachments) ? { attachments } : {}),
|
||||
});
|
||||
chatStore.addRoomMessage(roomId, {
|
||||
role: "assistant",
|
||||
content: "room reply",
|
||||
senderAgentId: "agent-room",
|
||||
mentions: ["agent-room"],
|
||||
});
|
||||
return { userMessage, responders: ["agent-room"] };
|
||||
},
|
||||
sendRoomMessage,
|
||||
} as any,
|
||||
});
|
||||
|
||||
@@ -195,13 +196,38 @@ describe("Chat HTTP + SSE routes — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(postRes.status).toBe(201);
|
||||
expect(sendRoomMessage).toHaveBeenCalledWith(roomId, "hello @agent_room", undefined);
|
||||
const messageId = (postRes.body as any).message.id as string;
|
||||
const persisted = chatStore.getRoomMessage(messageId);
|
||||
expect(persisted?.content).toBe("hello @agent_room");
|
||||
|
||||
const attachments = [{ id: "att-room-1", filename: "room.txt", originalName: "room.txt", mimeType: "text/plain", size: 4, createdAt: new Date().toISOString() }];
|
||||
const attachmentOnly = await request(
|
||||
appWithRoomReplies,
|
||||
"POST",
|
||||
`/api/chat/rooms/${roomId}/messages`,
|
||||
JSON.stringify({ content: " ", attachments }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(attachmentOnly.status).toBe(201);
|
||||
expect(sendRoomMessage).toHaveBeenCalledWith(roomId, "", attachments);
|
||||
|
||||
const emptyWithoutAttachments = await request(
|
||||
appWithRoomReplies,
|
||||
"POST",
|
||||
`/api/chat/rooms/${roomId}/messages`,
|
||||
JSON.stringify({ content: "" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(emptyWithoutAttachments.status).toBe(400);
|
||||
expect((emptyWithoutAttachments.body as any).error).toContain("content is required");
|
||||
|
||||
const assistantMessages = chatStore.getRoomMessages(roomId).filter((entry) => entry.role === "assistant");
|
||||
expect(assistantMessages).toHaveLength(1);
|
||||
expect(assistantMessages[0]).toMatchObject({ senderAgentId: "agent-room" });
|
||||
expect(assistantMessages).toHaveLength(2);
|
||||
expect(assistantMessages).toEqual([
|
||||
expect.objectContaining({ senderAgentId: "agent-room" }),
|
||||
expect.objectContaining({ senderAgentId: "agent-room" }),
|
||||
]);
|
||||
|
||||
const invalidSender = await request(
|
||||
appWithRoomReplies,
|
||||
|
||||
@@ -331,14 +331,23 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext, deps: ChatRoomRout
|
||||
attachments?: ChatAttachment[];
|
||||
};
|
||||
|
||||
if (!content || typeof content !== "string" || !content.trim()) {
|
||||
const messageAttachments = Array.isArray(attachments) ? attachments : undefined;
|
||||
if (content !== undefined && typeof content !== "string") {
|
||||
throw badRequest("content is required and must be a non-empty string");
|
||||
}
|
||||
const trimmedContent = content?.trim() ?? "";
|
||||
/**
|
||||
* FNXC:ChatRooms 2026-06-17-02:12:
|
||||
* Room chat must accept attachment-only messages from main and quick chat while continuing to reject fully empty sends with no text and no attachment references.
|
||||
*/
|
||||
if (!trimmedContent && (messageAttachments?.length ?? 0) === 0) {
|
||||
throw badRequest("content is required and must be a non-empty string");
|
||||
}
|
||||
if (senderAgentId !== undefined && senderAgentId !== null) {
|
||||
throw badRequest("senderAgentId is reserved for FN-3810; must be null or omitted");
|
||||
}
|
||||
|
||||
const result = await services.chatManager.sendRoomMessage(roomId, content.trim(), Array.isArray(attachments) ? attachments : undefined);
|
||||
const result = await services.chatManager.sendRoomMessage(roomId, trimmedContent, messageAttachments);
|
||||
res.status(201).json({ message: result.userMessage });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
|
||||
@@ -586,8 +586,18 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
};
|
||||
const { content, modelProvider, modelId, attachments } = body;
|
||||
const sessionId = String(req.params.id);
|
||||
|
||||
if (!content || typeof content !== "string" || !content.trim()) {
|
||||
const uploadedFiles = Array.isArray(req.files) ? (req.files as Express.Multer.File[]) : [];
|
||||
const referencedAttachments = Array.isArray(attachments) ? attachments : undefined;
|
||||
const hasAttachments = uploadedFiles.length > 0 || (referencedAttachments?.length ?? 0) > 0;
|
||||
if (content !== undefined && typeof content !== "string") {
|
||||
throw badRequest("content is required and must be a non-empty string");
|
||||
}
|
||||
const trimmedContent = content?.trim() ?? "";
|
||||
/**
|
||||
* FNXC:Chat 2026-06-17-02:12:
|
||||
* Attachment-only chat sends are valid user messages. Reject only payloads that have neither text nor uploaded/referenced attachments so Quick Chat and Main Chat can submit files without filler text.
|
||||
*/
|
||||
if (!trimmedContent && !hasAttachments) {
|
||||
throw badRequest("content is required and must be a non-empty string");
|
||||
}
|
||||
|
||||
@@ -597,16 +607,13 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
throw notFound(`Chat session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
const uploadedFiles = Array.isArray(req.files) ? (req.files as Express.Multer.File[]) : [];
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const uploadedAttachments = uploadedFiles.length > 0
|
||||
? await Promise.all(uploadedFiles.map((file) => persistChatAttachment(file, scopedStore.getRootDir(), sessionId)))
|
||||
: undefined;
|
||||
const messageAttachments = uploadedAttachments && uploadedAttachments.length > 0
|
||||
? uploadedAttachments
|
||||
: Array.isArray(attachments)
|
||||
? attachments
|
||||
: undefined;
|
||||
: referencedAttachments;
|
||||
|
||||
// Resolve per-project ChatManager before opening the SSE stream so
|
||||
// failures (e.g. project DB cannot be opened) produce a proper HTTP error.
|
||||
@@ -704,7 +711,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
// Fire and forget - streaming happens via callbacks
|
||||
chatManager.sendMessage(
|
||||
sessionId,
|
||||
content.trim(),
|
||||
trimmedContent,
|
||||
normalizedProvider,
|
||||
normalizedModelId,
|
||||
messageAttachments,
|
||||
|
||||
Reference in New Issue
Block a user