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:
gsxdsm
2026-06-17 02:39:36 -07:00
parent a998f63242
commit 4a9fe9957d
8 changed files with 205 additions and 32 deletions

View 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.

View File

@@ -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. - 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. - 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 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.
![Chat view](./screenshots/chat-view.png) ![Chat view](./screenshots/chat-view.png)

View File

@@ -2025,7 +2025,12 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
const handleSendDispatch = useCallback(async () => { const handleSendDispatch = useCallback(async () => {
const trimmed = messageInput.trim(); 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; return;
} }
@@ -2053,7 +2058,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
clearComposerState(); clearComposerState();
try { try {
await rooms.sendRoomMessage(trimmed, { files: pendingAttachments.map((attachment) => attachment.file) }); await rooms.sendRoomMessage(trimmed, { files });
} catch (error) { } catch (error) {
if (error instanceof RoomMessageDeliveredButReplyFailedError) { if (error instanceof RoomMessageDeliveredButReplyFailedError) {
const message = error.message.trim() const message = error.message.trim()
@@ -3635,8 +3640,66 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
{rooms.activeRoom && ( {rooms.activeRoom && (
<div className="chat-input-area"> <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-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 <textarea
ref={handleComposerRef} ref={handleComposerRef}
className="chat-input-textarea" className="chat-input-textarea"
@@ -3648,6 +3711,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
onClick={handleInputSelectionChange} onClick={handleInputSelectionChange}
onBlur={handleInputBlur} onBlur={handleInputBlur}
onFocus={handleInputFocus} onFocus={handleInputFocus}
onPaste={handlePaste}
onTouchStart={(event) => { onTouchStart={(event) => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
if (window.innerWidth > 768) return; if (window.innerWidth > 768) return;
@@ -3693,7 +3757,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
onClick={() => { onClick={() => {
void handleSendDispatch(); void handleSendDispatch();
}} }}
disabled={!messageInput.trim()} disabled={!messageInput.trim() && pendingAttachments.length === 0}
data-testid="chat-send-btn" data-testid="chat-send-btn"
style={{ touchAction: "manipulation" }} style={{ touchAction: "manipulation" }}
> >

View File

@@ -1632,6 +1632,45 @@ describe("ChatView", () => {
localStorage.removeItem("fusion:chat-scope"); 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 () => { it("keeps direct chat send behavior unchanged when chat rooms are enabled", async () => {
localStorage.setItem("fusion:chat-scope", "direct"); localStorage.setItem("fusion:chat-scope", "direct");
const sendMessage = vi.fn(); const sendMessage = vi.fn();

View File

@@ -205,6 +205,14 @@ describe("chat attachment routes", () => {
expect(mockSendMessage).toHaveBeenCalledWith(session.id, "hello", undefined, undefined, attachments, { generationId: 1 }); 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 () => { it("passes multipart file attachments on message send", async () => {
const { payload, boundary } = makeMultipartMessageRequest("hello", "x.txt", "text/plain", Buffer.from("x")); 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); 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 { 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); 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.status).toBe(400);
expect((response.body as any).error).toContain("content is required"); expect((response.body as any).error).toContain("content is required");
expect(mockSendMessage).not.toHaveBeenCalled();
}); });
afterEach(() => { afterEach(() => {

View File

@@ -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 () => { it("covers room message route contracts: trim, senderAgentId rejection, before cursor, delete idempotency, attachments", async () => {
const { createServer } = await import("../server.js"); 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, { const appWithRoomReplies = createServer(store as any, {
chatStore, chatStore,
chatManager: { chatManager: {
sendRoomMessage: async (roomId: string, content: string, attachments?: any[]) => { sendRoomMessage,
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"] };
},
} as any, } as any,
}); });
@@ -195,13 +196,38 @@ describe("Chat HTTP + SSE routes — rooms (FN-3805..FN-3811 contract)", () => {
{ "content-type": "application/json" }, { "content-type": "application/json" },
); );
expect(postRes.status).toBe(201); expect(postRes.status).toBe(201);
expect(sendRoomMessage).toHaveBeenCalledWith(roomId, "hello @agent_room", undefined);
const messageId = (postRes.body as any).message.id as string; const messageId = (postRes.body as any).message.id as string;
const persisted = chatStore.getRoomMessage(messageId); const persisted = chatStore.getRoomMessage(messageId);
expect(persisted?.content).toBe("hello @agent_room"); 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"); const assistantMessages = chatStore.getRoomMessages(roomId).filter((entry) => entry.role === "assistant");
expect(assistantMessages).toHaveLength(1); expect(assistantMessages).toHaveLength(2);
expect(assistantMessages[0]).toMatchObject({ senderAgentId: "agent-room" }); expect(assistantMessages).toEqual([
expect.objectContaining({ senderAgentId: "agent-room" }),
expect.objectContaining({ senderAgentId: "agent-room" }),
]);
const invalidSender = await request( const invalidSender = await request(
appWithRoomReplies, appWithRoomReplies,

View File

@@ -331,14 +331,23 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext, deps: ChatRoomRout
attachments?: ChatAttachment[]; 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"); throw badRequest("content is required and must be a non-empty string");
} }
if (senderAgentId !== undefined && senderAgentId !== null) { if (senderAgentId !== undefined && senderAgentId !== null) {
throw badRequest("senderAgentId is reserved for FN-3810; must be null or omitted"); 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 }); res.status(201).json({ message: result.userMessage });
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) throw err; if (err instanceof ApiError) throw err;

View File

@@ -586,8 +586,18 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
}; };
const { content, modelProvider, modelId, attachments } = body; const { content, modelProvider, modelId, attachments } = body;
const sessionId = String(req.params.id); const sessionId = String(req.params.id);
const uploadedFiles = Array.isArray(req.files) ? (req.files as Express.Multer.File[]) : [];
if (!content || typeof content !== "string" || !content.trim()) { 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"); 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`); 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 { store: scopedStore } = await getProjectContext(req);
const uploadedAttachments = uploadedFiles.length > 0 const uploadedAttachments = uploadedFiles.length > 0
? await Promise.all(uploadedFiles.map((file) => persistChatAttachment(file, scopedStore.getRootDir(), sessionId))) ? await Promise.all(uploadedFiles.map((file) => persistChatAttachment(file, scopedStore.getRootDir(), sessionId)))
: undefined; : undefined;
const messageAttachments = uploadedAttachments && uploadedAttachments.length > 0 const messageAttachments = uploadedAttachments && uploadedAttachments.length > 0
? uploadedAttachments ? uploadedAttachments
: Array.isArray(attachments) : referencedAttachments;
? attachments
: undefined;
// Resolve per-project ChatManager before opening the SSE stream so // Resolve per-project ChatManager before opening the SSE stream so
// failures (e.g. project DB cannot be opened) produce a proper HTTP error. // 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 // Fire and forget - streaming happens via callbacks
chatManager.sendMessage( chatManager.sendMessage(
sessionId, sessionId,
content.trim(), trimmedContent,
normalizedProvider, normalizedProvider,
normalizedModelId, normalizedModelId,
messageAttachments, messageAttachments,