feat(FN-4857): complete Step 4 — upload room attachments in useChatRooms

Fusion-Task-Id: FN-4857
Fusion-Task-Lineage: 52e7b1cd-ec8f-4b6c-abfc-08c91e321b9e
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 01:18:46 -07:00
committed by gsxdsm
parent 354dae2d54
commit a9e76bff81
2 changed files with 89 additions and 5 deletions

View File

@@ -13,6 +13,7 @@ vi.mock("../../api", () => ({
fetchChatRoomMessages: vi.fn(),
deleteChatRoom: vi.fn(),
postChatRoomMessage: vi.fn(),
uploadChatRoomAttachment: vi.fn(),
clearChatRoomMessages: vi.fn(),
}));
@@ -32,6 +33,7 @@ const mockFetchChatRoomMembers = vi.mocked(apiModule.fetchChatRoomMembers);
const mockFetchChatRoomMessages = vi.mocked(apiModule.fetchChatRoomMessages);
const mockDeleteChatRoom = vi.mocked(apiModule.deleteChatRoom);
const mockPostChatRoomMessage = vi.mocked(apiModule.postChatRoomMessage);
const mockUploadChatRoomAttachment = vi.mocked(apiModule.uploadChatRoomAttachment);
const mockClearChatRoomMessages = vi.mocked(apiModule.clearChatRoomMessages);
const mockSubscribeSse = vi.mocked(sseBusModule.subscribeSse);
@@ -86,6 +88,16 @@ describe("useChatRooms", () => {
mockCreateChatRoom.mockResolvedValue({ room: room("room-new", "new", "2026-05-09T01:00:00.000Z") });
mockDeleteChatRoom.mockResolvedValue({ success: true });
mockPostChatRoomMessage.mockResolvedValue({ message: roomMessage("msg-posted", "room-new", "posted") });
mockUploadChatRoomAttachment.mockResolvedValue({
attachment: {
id: "att-uploaded",
filename: "upload.png",
originalName: "upload.png",
mimeType: "image/png",
size: 4,
createdAt: "2026-05-09T00:00:00.000Z",
},
});
mockClearChatRoomMessages.mockResolvedValue({ success: true, deletedCount: 1 });
});
@@ -288,6 +300,53 @@ describe("useChatRooms", () => {
expect(result.current.messages.map((message) => message.id)).toEqual(["msg-user", "msg-assistant"]);
});
it("uploads files before posting room message", async () => {
const active = room("room-1", "one", "2026-05-09T01:00:00.000Z");
mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] });
const { result } = renderHook(() => useChatRooms("proj-1"));
await waitFor(() => expect(result.current.rooms.length).toBe(1));
mockFetchChatRoomMembers.mockResolvedValueOnce({ members: [] });
mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: [] });
act(() => result.current.selectRoom("room-1"));
await waitFor(() => expect(result.current.activeRoom?.id).toBe("room-1"));
const file = new File(["png"], "upload.png", { type: "image/png" });
mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: [roomMessage("msg-user", "room-1", "hello")] });
await act(async () => {
await result.current.sendRoomMessage("hello", { files: [file] });
});
expect(mockUploadChatRoomAttachment).toHaveBeenCalledWith("room-1", file, "proj-1");
expect(mockPostChatRoomMessage).toHaveBeenCalledWith("room-1", {
content: "hello",
attachments: [expect.objectContaining({ id: "att-uploaded", filename: "upload.png" })],
}, "proj-1");
});
it("throws on upload failure and does not post", async () => {
const active = room("room-1", "one", "2026-05-09T01:00:00.000Z");
mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] });
const { result } = renderHook(() => useChatRooms("proj-1"));
await waitFor(() => expect(result.current.rooms.length).toBe(1));
mockFetchChatRoomMembers.mockResolvedValueOnce({ members: [] });
mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: [] });
act(() => result.current.selectRoom("room-1"));
await waitFor(() => expect(result.current.activeRoom?.id).toBe("room-1"));
const file = new File(["x"], "bad.txt", { type: "text/plain" });
mockUploadChatRoomAttachment.mockRejectedValueOnce(new Error("Upload failed"));
mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: [] });
await act(async () => {
await expect(result.current.sendRoomMessage("hello", { files: [file] })).rejects.toThrow("Failed to upload attachment: bad.txt");
});
expect(mockPostChatRoomMessage).not.toHaveBeenCalledWith("room-1", expect.objectContaining({ content: "hello" }), "proj-1");
});
it("rolls back optimistic temp message when post fails and transcript refresh fails", async () => {
const active = room("room-1", "one", "2026-05-09T01:00:00.000Z");
mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] });

View File

@@ -8,6 +8,7 @@ import {
fetchChatRoomMessages,
fetchChatRooms,
postChatRoomMessage,
uploadChatRoomAttachment,
} from "../api";
import { subscribeSse } from "../sse-bus";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
@@ -26,7 +27,7 @@ export interface UseChatRoomsResult {
selectRoom: (roomId: string | null) => void;
createRoom: (input: { name: string; memberAgentIds: string[] }) => Promise<ChatRoom>;
deleteRoom: (roomId: string) => Promise<void>;
sendRoomMessage: (content: string, opts?: { attachments?: ChatAttachment[] }) => Promise<void>;
sendRoomMessage: (content: string, opts?: { attachments?: ChatAttachment[]; files?: File[] }) => Promise<void>;
clearRoom: (roomId: string) => Promise<void>;
refreshRooms: () => Promise<void>;
}
@@ -51,7 +52,7 @@ function parseSsePayload<T>(event: MessageEvent): T | null {
}
}
function createOptimisticRoomMessage(roomId: string, content: string): ChatRoomMessage {
function createOptimisticRoomMessage(roomId: string, content: string, attachments?: ChatAttachment[]): ChatRoomMessage {
return {
id: `temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
roomId,
@@ -61,6 +62,7 @@ function createOptimisticRoomMessage(roomId: string, content: string): ChatRoomM
metadata: null,
senderAgentId: null,
mentions: [],
...(attachments?.length ? { attachments } : {}),
createdAt: new Date().toISOString(),
};
}
@@ -203,22 +205,45 @@ export function useChatRooms(
}
}, [activeRoomCacheKey, projectId]);
const sendRoomMessage = useCallback(async (content: string, opts?: { attachments?: ChatAttachment[] }) => {
const sendRoomMessage = useCallback(async (content: string, opts?: { attachments?: ChatAttachment[]; files?: File[] }) => {
const activeRoomSnapshot = activeRoomRef.current;
const roomId = activeRoomSnapshot?.id;
if (!roomId) {
throw new Error("Select a room before sending a message");
}
const optimisticMessage = createOptimisticRoomMessage(roomId, content);
const placeholderAttachments = opts?.files?.map((file) => ({
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
filename: file.name,
originalName: file.name,
mimeType: file.type || "application/octet-stream",
size: file.size,
createdAt: new Date().toISOString(),
} satisfies ChatAttachment));
const optimisticMessage = createOptimisticRoomMessage(roomId, content, placeholderAttachments?.length ? placeholderAttachments : opts?.attachments);
if (activeRoomRef.current?.id === roomId) {
setMessages((previous) => [...previous, optimisticMessage]);
}
try {
const uploadedAttachments: ChatAttachment[] = [];
if (opts?.files?.length) {
for (const file of opts.files) {
try {
const uploaded = await uploadChatRoomAttachment(roomId, file, projectId);
uploadedAttachments.push(uploaded.attachment);
} catch {
throw new Error(`Failed to upload attachment: ${file.name}`);
}
}
}
const mergedAttachments = [...(opts?.attachments ?? []), ...uploadedAttachments];
const postResult = await postChatRoomMessage(roomId, {
content,
...(opts?.attachments ? { attachments: opts.attachments } : {}),
...(mergedAttachments.length ? { attachments: mergedAttachments } : {}),
}, projectId);
if (postResult.message?.createdAt && activeRoomSnapshot) {