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

View File

@@ -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] });

View File

@@ -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];
});
},