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:
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,7 +182,7 @@ describe("useChatRooms", () => {
|
||||
expect(result.current.activeRoom).toBeNull();
|
||||
});
|
||||
|
||||
it("sendRoomMessage resyncs room messages from server after post", async () => {
|
||||
it("sendRoomMessage inserts optimistic temp message and reconciles to server transcript", async () => {
|
||||
const active = room("room-1", "one", "2026-05-09T01:00:00.000Z");
|
||||
mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] });
|
||||
const { result } = renderHook(() => useChatRooms("proj-1"));
|
||||
@@ -193,6 +193,12 @@ describe("useChatRooms", () => {
|
||||
act(() => result.current.selectRoom("room-1"));
|
||||
await waitFor(() => expect(result.current.activeRoom?.id).toBe("room-1"));
|
||||
|
||||
let resolvePost: ((value: { message: ChatRoomMessage }) => void) | undefined;
|
||||
const postPromise = new Promise<{ message: ChatRoomMessage }>((resolve) => {
|
||||
resolvePost = resolve;
|
||||
});
|
||||
mockPostChatRoomMessage.mockReturnValueOnce(postPromise);
|
||||
|
||||
mockFetchChatRoomMessages.mockResolvedValueOnce({
|
||||
messages: [
|
||||
roomMessage("msg-user", "room-1", "hello"),
|
||||
@@ -200,8 +206,19 @@ describe("useChatRooms", () => {
|
||||
],
|
||||
});
|
||||
|
||||
let sendPromise!: Promise<void>;
|
||||
await act(async () => {
|
||||
await result.current.sendRoomMessage("hello");
|
||||
sendPromise = result.current.sendRoomMessage("hello");
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]?.id.startsWith("temp-")).toBe(true);
|
||||
expect(result.current.messages[0]?.content).toBe("hello");
|
||||
|
||||
resolvePost?.({ message: roomMessage("msg-user", "room-1", "hello") });
|
||||
|
||||
await act(async () => {
|
||||
await sendPromise;
|
||||
});
|
||||
|
||||
expect(mockPostChatRoomMessage).toHaveBeenCalledWith("room-1", { content: "hello" }, "proj-1");
|
||||
@@ -209,6 +226,27 @@ describe("useChatRooms", () => {
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual(["msg-user", "msg-assistant"]);
|
||||
});
|
||||
|
||||
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] });
|
||||
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"));
|
||||
|
||||
mockPostChatRoomMessage.mockRejectedValueOnce(new Error("POST failed"));
|
||||
mockFetchChatRoomMessages.mockRejectedValueOnce(new Error("refresh failed"));
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.sendRoomMessage("hello")).rejects.toThrow("POST failed");
|
||||
});
|
||||
|
||||
expect(result.current.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("refreshes persisted room messages even when room reply generation fails", async () => {
|
||||
const active = room("room-1", "one", "2026-05-09T01:00:00.000Z");
|
||||
mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] });
|
||||
|
||||
@@ -48,6 +48,20 @@ function parseSsePayload<T>(event: MessageEvent): T | null {
|
||||
}
|
||||
}
|
||||
|
||||
function createOptimisticRoomMessage(roomId: string, content: string): ChatRoomMessage {
|
||||
return {
|
||||
id: `temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
roomId,
|
||||
role: "user",
|
||||
content,
|
||||
thinkingOutput: null,
|
||||
metadata: null,
|
||||
senderAgentId: null,
|
||||
mentions: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function useChatRooms(
|
||||
projectId?: string,
|
||||
addToast?: (msg: string, type?: "success" | "error" | "warning") => void,
|
||||
@@ -174,6 +188,11 @@ export function useChatRooms(
|
||||
throw new Error("Select a room before sending a message");
|
||||
}
|
||||
|
||||
const optimisticMessage = createOptimisticRoomMessage(roomId, content);
|
||||
if (activeRoomRef.current?.id === roomId) {
|
||||
setMessages((previous) => [...previous, optimisticMessage]);
|
||||
}
|
||||
|
||||
try {
|
||||
const postResult = await postChatRoomMessage(roomId, {
|
||||
content,
|
||||
@@ -184,6 +203,11 @@ export function useChatRooms(
|
||||
setRooms((previous) => upsertRoom(previous, { ...activeRoomSnapshot, updatedAt: postResult.message.createdAt }));
|
||||
}
|
||||
|
||||
if (activeRoomRef.current?.id === roomId) {
|
||||
setMessages((previous) => previous.map((message) =>
|
||||
message.id === optimisticMessage.id ? postResult.message : message));
|
||||
}
|
||||
|
||||
const latestMessages = await fetchChatRoomMessages(roomId, { limit: 100 }, projectId);
|
||||
if (activeRoomRef.current?.id !== roomId) {
|
||||
return;
|
||||
@@ -196,7 +220,9 @@ export function useChatRooms(
|
||||
setMessages(latestMessages.messages);
|
||||
}
|
||||
} catch {
|
||||
// Ignore refresh failures and preserve the original error.
|
||||
if (activeRoomRef.current?.id === roomId) {
|
||||
setMessages((previous) => previous.filter((message) => message.id !== optimisticMessage.id));
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -275,6 +301,19 @@ export function useChatRooms(
|
||||
if (previous.some((candidate) => candidate.id === message.id)) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
if (message.role === "user") {
|
||||
const optimisticIndex = previous.findIndex((candidate) =>
|
||||
candidate.role === "user"
|
||||
&& candidate.id.startsWith("temp-")
|
||||
&& candidate.content.trim() === message.content.trim());
|
||||
if (optimisticIndex >= 0) {
|
||||
const next = [...previous];
|
||||
next[optimisticIndex] = message;
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
return [...previous, message];
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user