feat(FN-4857): complete Step 5 — enable room sends with attachments in QuickChat
Fusion-Task-Id: FN-4857 Fusion-Task-Lineage: 52e7b1cd-ec8f-4b6c-abfc-08c91e321b9e
This commit is contained in:
committed by
gsxdsm
parent
a9e76bff81
commit
c7f98f8381
@@ -14,7 +14,7 @@ import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
import { ChevronDown, Eye, EyeOff, Hash, MessageSquare, Paperclip, Plus, Send, Square, Wrench, X } from "lucide-react";
|
||||
import { fetchDiscoveredSkills, fetchModels, type Agent, type ModelInfo } from "../api";
|
||||
import { attachmentBaseUrlForRoom, fetchDiscoveredSkills, fetchModels, type Agent, type ModelInfo } from "../api";
|
||||
import type { DiscoveredSkill } from "@fusion/dashboard";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
@@ -770,6 +770,7 @@ interface QuickChatMessageItemProps {
|
||||
forcePlain: boolean;
|
||||
mentionAgentsByName: Map<string, Agent>;
|
||||
roomContext: QuickChatRoomContext | null;
|
||||
projectId?: string;
|
||||
onToggleRender: (id: string) => void;
|
||||
}
|
||||
|
||||
@@ -780,6 +781,7 @@ const QuickChatMessageItem = memo(function QuickChatMessageItem({
|
||||
forcePlain,
|
||||
mentionAgentsByName,
|
||||
roomContext,
|
||||
projectId,
|
||||
onToggleRender,
|
||||
}: QuickChatMessageItemProps) {
|
||||
const isSent = message.role === "user";
|
||||
@@ -834,6 +836,31 @@ const QuickChatMessageItem = memo(function QuickChatMessageItem({
|
||||
);
|
||||
}, [isSent, forcePlain, message.content]);
|
||||
|
||||
const renderedAttachments = useMemo(() => {
|
||||
if (!message.attachments?.length || !message.roomId) return null;
|
||||
const baseUrl = attachmentBaseUrlForRoom(message.roomId, projectId);
|
||||
return (
|
||||
<div className="chat-message-attachments">
|
||||
{message.attachments.map((attachment) => {
|
||||
const href = `${baseUrl}${encodeURIComponent(attachment.filename)}`;
|
||||
const isImage = attachment.mimeType.startsWith("image/");
|
||||
return (
|
||||
<a
|
||||
key={attachment.id}
|
||||
className="chat-message-attachment"
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-testid="quick-chat-message-attachment"
|
||||
>
|
||||
{isImage ? <img src={href} alt={attachment.originalName} className="chat-attachment-image" loading="lazy" /> : <span>{attachment.originalName}</span>}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}, [message.attachments, message.roomId, projectId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`quick-chat-panel-message ${isSent ? "quick-chat-panel-message--sent" : "quick-chat-panel-message--received"}`}
|
||||
@@ -855,6 +882,7 @@ const QuickChatMessageItem = memo(function QuickChatMessageItem({
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{renderedAttachments}
|
||||
{renderToolCalls(message.toolCalls, true)}
|
||||
</div>
|
||||
);
|
||||
@@ -1137,14 +1165,20 @@ export function QuickChatFAB({
|
||||
return roomsState.messages.map((message) => ({
|
||||
id: message.id,
|
||||
sessionId: message.roomId,
|
||||
roomId: message.roomId,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
thinkingOutput: message.thinkingOutput,
|
||||
toolCalls: undefined,
|
||||
attachments: message.attachments,
|
||||
createdAt: message.createdAt,
|
||||
}));
|
||||
}, [messages, roomThreadActive, roomsState.messages]);
|
||||
const inputDisabled = roomThreadActive ? false : (!hasChatTarget || !activeSession);
|
||||
const sendDisabled =
|
||||
(messageInput.trim().length === 0 && pendingAttachments.length === 0)
|
||||
|| (!roomThreadActive && !hasChatTarget)
|
||||
|| (!roomThreadActive && !activeSession);
|
||||
const hasPersistedAgentSessionSelection = useMemo(
|
||||
() => Boolean(selectedAgentId) && sessions.some((session) => !session.modelProvider && !session.modelId && session.agentId === selectedAgentId),
|
||||
[selectedAgentId, sessions],
|
||||
@@ -1941,13 +1975,6 @@ export function QuickChatFAB({
|
||||
return;
|
||||
}
|
||||
|
||||
if (roomThreadActive && attachmentsToSend.length > 0) {
|
||||
addToast("Attachments are not supported in chat rooms yet", "warning");
|
||||
focusComposerInput();
|
||||
preserveComposerFocusRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed === "/clear" || trimmed === "/new") {
|
||||
attachmentsToSend.forEach((attachment) => {
|
||||
if (attachment.previewUrl) {
|
||||
@@ -1984,8 +2011,8 @@ export function QuickChatFAB({
|
||||
|
||||
try {
|
||||
setHelpMessageVisible(false);
|
||||
if (roomThreadActive) {
|
||||
await roomsState.sendRoomMessage(trimmed);
|
||||
if (chatRoomsEnabled && roomsState.activeRoom) {
|
||||
await roomsState.sendRoomMessage(trimmed, { files: attachmentsToSend.map((attachment) => attachment.file) });
|
||||
} else {
|
||||
await sendMessage(trimmed, attachmentsToSend.map((attachment) => attachment.file));
|
||||
}
|
||||
@@ -1995,7 +2022,11 @@ export function QuickChatFAB({
|
||||
}
|
||||
});
|
||||
setPendingAttachments((previous) => previous.filter((attachment) => !attachmentsToSend.includes(attachment)));
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: (chatRoomsEnabled && roomsState.activeRoom ? "Failed to send room message" : "Failed to send message");
|
||||
addToast(message, "error");
|
||||
// Keep pending attachments on failure so user can retry.
|
||||
} finally {
|
||||
focusComposerInput();
|
||||
@@ -2004,6 +2035,7 @@ export function QuickChatFAB({
|
||||
}, [
|
||||
addToast,
|
||||
chatMode,
|
||||
chatRoomsEnabled,
|
||||
clearPendingMessage,
|
||||
focusComposerInput,
|
||||
inputDisabled,
|
||||
@@ -2759,6 +2791,7 @@ export function QuickChatFAB({
|
||||
forcePlain={message.role !== "user" && plainTextMessageIds.has(message.id)}
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
roomContext={roomContext}
|
||||
projectId={projectId}
|
||||
onToggleRender={toggleMessageRenderMode}
|
||||
/>
|
||||
))}
|
||||
@@ -2813,6 +2846,7 @@ export function QuickChatFAB({
|
||||
forcePlain={message.role !== "user" && plainTextMessageIds.has(message.id)}
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
roomContext={roomContext}
|
||||
projectId={projectId}
|
||||
onToggleRender={toggleMessageRenderMode}
|
||||
/>
|
||||
))}
|
||||
@@ -2835,6 +2869,7 @@ export function QuickChatFAB({
|
||||
forcePlain={message.role !== "user" && plainTextMessageIds.has(message.id)}
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
roomContext={roomContext}
|
||||
projectId={projectId}
|
||||
onToggleRender={toggleMessageRenderMode}
|
||||
/>
|
||||
))}
|
||||
@@ -3013,7 +3048,7 @@ export function QuickChatFAB({
|
||||
}
|
||||
void handleSendMessage();
|
||||
}}
|
||||
disabled={inputDisabled || (messageInput.trim().length === 0 && pendingAttachments.length === 0)}
|
||||
disabled={sendDisabled}
|
||||
data-testid="quick-chat-send"
|
||||
>
|
||||
<Send size={16} />
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("../../api", () => ({
|
||||
fetchModels: vi.fn(),
|
||||
fetchDiscoveredSkills: vi.fn(),
|
||||
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
|
||||
attachmentBaseUrlForRoom: vi.fn((roomId: string) => `/api/chat/rooms/${roomId}/attachments/`),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useAgents", () => ({ useAgents: vi.fn() }));
|
||||
@@ -1491,7 +1492,7 @@ describe("QuickChatFAB session-first UX", () => {
|
||||
expect(screen.getByTestId("quick-chat-input")).toHaveAttribute("placeholder", "Message #engineering");
|
||||
});
|
||||
|
||||
it("sending while in a room routes to sendRoomMessage, not sendMessage", async () => {
|
||||
it("sending while in a room routes attachments to sendRoomMessage, not sendMessage", async () => {
|
||||
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
|
||||
mockUseAppSettings.mockReturnValue({ experimentalFeatures: { chatRooms: true } } as ReturnType<typeof useAppSettings>);
|
||||
mockUseChatRooms.mockReturnValue({
|
||||
@@ -1503,11 +1504,15 @@ describe("QuickChatFAB session-first UX", () => {
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
const input = await screen.findByTestId("quick-chat-input");
|
||||
const attachmentInput = document.querySelector(".quick-chat-attachment-input") as HTMLInputElement | null;
|
||||
const file = new File(["hi"], "note.txt", { type: "text/plain" });
|
||||
expect(attachmentInput).not.toBeNull();
|
||||
fireEvent.change(attachmentInput!, { target: { files: [file] } });
|
||||
fireEvent.change(input, { target: { value: "room dispatch" } });
|
||||
fireEvent.click(screen.getByTestId("quick-chat-send"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendRoomMessage).toHaveBeenCalledWith("room dispatch");
|
||||
expect(sendRoomMessage).toHaveBeenCalledWith("room dispatch", { files: [file] });
|
||||
});
|
||||
expect(mockStreamChatResponse).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1578,9 +1583,43 @@ describe("QuickChatFAB session-first UX", () => {
|
||||
expect(await screen.findByTestId("quick-chat-messages")).toHaveTextContent("hello from session");
|
||||
});
|
||||
|
||||
it("attachment-in-room is blocked with a warning toast", async () => {
|
||||
it("renders room message attachments with room attachment URLs", async () => {
|
||||
mockUseAppSettings.mockReturnValue({ experimentalFeatures: { chatRooms: true } } as ReturnType<typeof useAppSettings>);
|
||||
mockUseChatRooms.mockReturnValue({
|
||||
rooms: [room],
|
||||
roomsLoading: false,
|
||||
roomsError: null,
|
||||
activeRoom: room,
|
||||
activeRoomMembers: [],
|
||||
messages: [
|
||||
{
|
||||
id: "room-msg-1",
|
||||
roomId: room.id,
|
||||
role: "assistant",
|
||||
content: "with attachment",
|
||||
attachments: [{ id: "att-1", filename: "file.png", originalName: "file.png", mimeType: "image/png", size: 10, createdAt: "2026-05-16T00:00:01.000Z" }],
|
||||
createdAt: "2026-05-16T00:00:01.000Z",
|
||||
},
|
||||
],
|
||||
messagesLoading: false,
|
||||
selectRoom: vi.fn(),
|
||||
createRoom: vi.fn(),
|
||||
deleteRoom: vi.fn(),
|
||||
sendRoomMessage: vi.fn(),
|
||||
clearRoom: vi.fn(),
|
||||
refreshRooms: vi.fn(),
|
||||
});
|
||||
|
||||
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
const attachment = await screen.findByTestId("quick-chat-message-attachment");
|
||||
expect(attachment).toHaveAttribute("href", expect.stringContaining("/api/chat/rooms/room-1/attachments/file.png"));
|
||||
});
|
||||
|
||||
it("room send failure keeps attachment previews and surfaces error toast", async () => {
|
||||
const addToast = vi.fn();
|
||||
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
|
||||
const sendRoomMessage = vi.fn().mockRejectedValue(new Error("upload failed"));
|
||||
mockUseAppSettings.mockReturnValue({ experimentalFeatures: { chatRooms: true } } as ReturnType<typeof useAppSettings>);
|
||||
mockUseChatRooms.mockReturnValue({
|
||||
rooms: [room], roomsLoading: false, roomsError: null, activeRoom: room, activeRoomMembers: [], messages: [], messagesLoading: false,
|
||||
@@ -1598,8 +1637,11 @@ describe("QuickChatFAB session-first UX", () => {
|
||||
fireEvent.change(input, { target: { value: "try send" } });
|
||||
fireEvent.click(screen.getByTestId("quick-chat-send"));
|
||||
|
||||
expect(addToast).toHaveBeenCalledWith("Attachments are not supported in chat rooms yet", "warning");
|
||||
expect(sendRoomMessage).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(sendRoomMessage).toHaveBeenCalledWith("try send", { files: [file] });
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith("upload failed", "error");
|
||||
expect(screen.getByTestId("quick-chat-attachment-previews")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user