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

View File

@@ -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" }}
>

View File

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

View File

@@ -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(() => {

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 () => {
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,

View File

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

View File

@@ -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,