feat(FN-4040): add optimistic room send with failure handling and reconcili

The merge implements optimistic room send with failure recovery in the ChatView and useChatRooms hook, including a reconciliation flow that handles send failures gracefully and documents the UX behavior. The dashboard guide and architecture docs are updated to reflect the new room messaging pattern.

Fusion-Task-Id: FN-4040
This commit is contained in:
Fusion
2026-05-11 16:45:34 -07:00
committed by gsxdsm
parent 2ccf8c699d
commit a2303c67a9
7 changed files with 124 additions and 14 deletions

View File

@@ -1314,13 +1314,21 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
if (!rooms.activeRoom) {
return;
}
await rooms.sendRoomMessage(trimmed);
clearComposerState();
try {
await rooms.sendRoomMessage(trimmed);
clearComposerState();
} catch (error) {
const message = error instanceof Error && error.message.trim()
? error.message
: "Failed to send room message";
addToast(message, "error");
}
return;
}
handleSend();
}, [messageInput, chatRoomsEnabled, chatScope, rooms, handleSend]);
}, [messageInput, chatRoomsEnabled, chatScope, rooms, clearComposerState, addToast, handleSend]);
const handleSkillSelect = useCallback(
(skill: DiscoveredSkill) => {

View File

@@ -190,6 +190,29 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
await waitFor(() => {
expect(sendRoomMessage).toHaveBeenCalledWith("Hello room");
});
await waitFor(() => {
expect((textarea as HTMLTextAreaElement).value).toBe("");
});
});
it("keeps room composer text and toasts once when room send fails", async () => {
const addToast = vi.fn();
const sendRoomMessage = vi.fn().mockRejectedValue(new Error("Room backend failed"));
setup({}, { sendRoomMessage, activeRoom: roomA });
render(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "Will retry{enter}");
await waitFor(() => {
expect(sendRoomMessage).toHaveBeenCalledWith("Will retry");
});
await waitFor(() => {
expect((textarea as HTMLTextAreaElement).value).toBe("Will retry");
});
expect(addToast).toHaveBeenCalledTimes(1);
expect(addToast).toHaveBeenCalledWith("Room backend failed", "error");
});
it("supports delete-room confirm/cancel and rerenders messages from hook state", async () => {
@@ -328,16 +351,18 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
it("keeps direct mode behavior unchanged when rooms are enabled", async () => {
localStorage.setItem("fusion:chat-scope", "direct");
const addToast = vi.fn();
const sendMessage = vi.fn();
const sendRoomMessage = vi.fn();
const sendRoomMessage = vi.fn().mockRejectedValue(new Error("Room backend failed"));
setup({ sendMessage }, { sendRoomMessage, activeRoom: roomA });
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
render(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "Direct hello{enter}");
expect(sendMessage).toHaveBeenCalledWith("Direct hello", []);
expect(sendRoomMessage).not.toHaveBeenCalled();
expect(addToast).not.toHaveBeenCalled();
});
});