feat(FN-4179): optimistically clear room composer on send
Implements optimistic room composer clear in the ChatView, providing instant UI feedback when switching rooms without waiting for server confirmation, with accompanying tests and documentation updates. Fusion-Task-Id: FN-4179 Fusion-Task-Lineage: 0c3588fa-81ca-464b-b001-506c1756d0ea
This commit is contained in:
5
.changeset/FN-4179-clear-room-composer.md
Normal file
5
.changeset/FN-4179-clear-room-composer.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Chat room composer now clears immediately when a message is sent and restores the typed text only if the send fails, matching standard chat UX.
|
||||
@@ -290,7 +290,7 @@ Intentional exclusions from shared snapshots:
|
||||
- Room responder prompt context is compacted deterministically: the newest 12 room messages stay verbatim, while older fetched history is summarized into a structured header (span, participants, and ranked highlights) before prompt size caps are enforced.
|
||||
- Room-reply generation is now non-silent on failure: if a room has members but no active responders can be resolved, or all responder generations fail/return empty output, `sendRoomMessage(...)` throws `RoomReplyGenerationError` and the route surfaces HTTP 502 instead of returning a silent user-only success.
|
||||
- `useChatRooms.sendRoomMessage()` now follows direct-chat style optimistic UX: append a temporary local user room message before `POST /api/chat/rooms/:id/messages`, reconcile that temp entry to the persisted user message on success, then refresh authoritative transcript state while continuing `chat:room:message:*` live SSE updates.
|
||||
- On failures, `useChatRooms.sendRoomMessage()` performs state reconciliation (rollback temp entry or replace with persisted transcript when POST partially succeeded) and rethrows; `ChatView` owns the single user-facing toast and keeps composer text for retry.
|
||||
- On failures, `useChatRooms.sendRoomMessage()` performs state reconciliation (rollback temp entry or replace with persisted transcript when POST partially succeeded) and rethrows; `ChatView` clears the composer immediately when dispatching a room send, restores the exact prior text only if the send rejects, and owns the single user-facing error toast.
|
||||
- Mention UI in rooms keeps direct-chat behavior unchanged while adding room affordances:
|
||||
- `AgentMentionPopup` receives room membership context and shows members first with a `status-dot` member indicator (`aria-label="Room member"`).
|
||||
- With an empty mention filter in room mode, only room members are listed; a hint row prompts the user to type to search non-members.
|
||||
|
||||
@@ -114,13 +114,13 @@ Chat Rooms are project-scoped group conversations for multiple agents. They are
|
||||
- Confirming delete calls `rooms.deleteRoom(roomId)` and permanently removes the room and its messages ("This action cannot be undone. This room and all its messages will be permanently deleted."); failures surface a `Failed to delete room` toast.
|
||||
- Selecting a room opens the room thread pane with loading and empty states, then renders room messages from `rooms.messages` as `ChatMessageInfo` entries in the same thread UI used for direct Chat.
|
||||
- Submitting the room composer calls `rooms.sendRoomMessage(...)`, which immediately inserts a temporary local user message and then posts to `POST /api/chat/rooms/:id/messages`.
|
||||
- On successful room send, the optimistic message is reconciled with persisted server data, the transcript is refreshed to authoritative history, and the composer clears (matching direct-chat clear-on-success behavior).
|
||||
- The room composer clears immediately when send is dispatched so the user gets instant feedback; on success the optimistic message is reconciled with persisted server data and the transcript is refreshed to authoritative history.
|
||||
- On mobile, room threads use the same keyboard-aware thread anchoring as direct chat, keeping the composer pinned above the soft keyboard while typing.
|
||||
- The dashboard backend now orchestrates room responders on that POST: mentioned members are routed as direct responders, additional ambient members may reply (up to the room ambient responder cap), and each assistant reply is persisted with `senderAgentId` via `chatStore.addRoomMessage(...)`.
|
||||
- If room replies cannot be generated (for example no resolvable responders or all responders fail), the POST fails with an API error (HTTP 502) instead of silently returning only the user message.
|
||||
- If room responders cannot be resolved or all room-reply generations fail, the POST now returns an error instead of silently succeeding with only the user message, so failures are surfaced deterministically.
|
||||
- Room responder prompt construction now keeps the most recent room messages verbatim and, when the room runs long, prepends a compacted summary of older history (span, participants, and key highlights) plus an explicit latest-user-message marker so replies stay thread-aware without unbounded prompt growth.
|
||||
- On send failure, `useChatRooms` rolls back/reconciles optimistic state and rethrows; `ChatView` catches once, preserves composer text for retry/edit, and surfaces a single error toast (no duplicate hook+view notifications).
|
||||
- On send failure, `useChatRooms` rolls back/reconciles optimistic state and rethrows; `ChatView` catches once, restores the exact pre-send composer text for retry/edit, and surfaces a single error toast (no duplicate hook+view notifications).
|
||||
- After each send attempt, the room transcript still re-fetches authoritative messages so persisted user/assistant replies remain visible even when SSE delivery is delayed, and `chat:room:message:*` SSE updates continue live fan-out.
|
||||
- Relationship summary: direct Chat runs one target (agent or model) per session; rooms are shared threads with multiple agent members and now use the same message contract as direct Chat; Quick Chat stays a floating single-target panel and does not host rooms.
|
||||
- For backend details, see the [Chat Room REST API reference](./architecture.md#real-time-channels) and the [chat room storage schema (`chat_rooms`, `chat_room_members`, `chat_room_messages`)](./storage.md#chat-rooms-migration-70).
|
||||
|
||||
@@ -1493,10 +1493,13 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
return;
|
||||
}
|
||||
|
||||
const previousInput = messageInput;
|
||||
clearComposerState();
|
||||
|
||||
try {
|
||||
await rooms.sendRoomMessage(trimmed);
|
||||
clearComposerState();
|
||||
} catch (error) {
|
||||
setMessageInput(previousInput);
|
||||
const message = error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Failed to send room message";
|
||||
|
||||
@@ -214,6 +214,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
beforeEach(() => {
|
||||
_resetInitialViewportHeight();
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
if (!window.matchMedia) {
|
||||
Object.defineProperty(window, "matchMedia", { value: vi.fn(), configurable: true, writable: true });
|
||||
}
|
||||
@@ -276,24 +277,57 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
|
||||
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"));
|
||||
let rejectSend: (error?: unknown) => void;
|
||||
const sendPromise = new Promise<undefined>((_, reject) => {
|
||||
rejectSend = reject;
|
||||
});
|
||||
const sendRoomMessage = vi.fn().mockReturnValue(sendPromise);
|
||||
setup({}, { sendRoomMessage, activeRoom: roomA });
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
||||
await userEvent.type(textarea, "Will retry{enter}");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendRoomMessage).toHaveBeenCalledWith("Will retry");
|
||||
});
|
||||
expect(textarea.value).toBe("");
|
||||
|
||||
rejectSend!(new Error("Room backend failed"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("Will retry");
|
||||
expect(textarea.value).toBe("Will retry");
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
expect(addToast).toHaveBeenCalledWith("Room backend failed", "error");
|
||||
});
|
||||
|
||||
it("clears room composer optimistically before send resolves", async () => {
|
||||
let resolveSend: () => void;
|
||||
const sendPromise = new Promise<void>((resolve) => {
|
||||
resolveSend = resolve;
|
||||
});
|
||||
const sendRoomMessage = vi.fn().mockReturnValue(sendPromise);
|
||||
setup({}, { sendRoomMessage, activeRoom: roomA });
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||
|
||||
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
||||
await userEvent.type(textarea, "Optimistic clear{enter}");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendRoomMessage).toHaveBeenCalledWith("Optimistic clear");
|
||||
});
|
||||
expect(textarea.value).toBe("");
|
||||
|
||||
resolveSend!();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(textarea.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("supports delete-room confirm/cancel and rerenders messages from hook state", async () => {
|
||||
const deleteRoom = vi.fn().mockResolvedValue(undefined);
|
||||
const rerenderedRooms = {
|
||||
|
||||
@@ -145,6 +145,10 @@ async function runOverlapMerge(dir: string, taskId: string, settingsOverrides: R
|
||||
return { store, result };
|
||||
}
|
||||
|
||||
function testTempParent(): string {
|
||||
return process.env.FUSION_TEST_WORKER_ROOT ?? tmpdir();
|
||||
}
|
||||
|
||||
function assertIsolatedWorkspace(dir: string): void {
|
||||
const repoRoot = process.env.FUSION_TEST_REAL_ROOT;
|
||||
if (!repoRoot) return;
|
||||
@@ -189,7 +193,7 @@ describe("merger overlap guard", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "fusion-test-overlap-guard-"));
|
||||
dir = mkdtempSync(join(testTempParent(), "fusion-test-overlap-guard-"));
|
||||
createdDirs.add(dir);
|
||||
assertIsolatedWorkspace(dir);
|
||||
initRepo(dir);
|
||||
@@ -336,7 +340,7 @@ describe("aiMergeTask overlap-aware fallback integration", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "fusion-test-overlap-merge-"));
|
||||
dir = mkdtempSync(join(testTempParent(), "fusion-test-overlap-merge-"));
|
||||
createdDirs.add(dir);
|
||||
assertIsolatedWorkspace(dir);
|
||||
initRepo(dir);
|
||||
|
||||
@@ -74,6 +74,10 @@ function squashBranch(dir: string, branchName: string, fileName: string, content
|
||||
// Minimal stub settings / args used by commitOrAmendMergeWithFixes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function testTempParent(): string {
|
||||
return process.env.FUSION_TEST_WORKER_ROOT ?? tmpdir();
|
||||
}
|
||||
|
||||
function assertIsolatedWorkspace(dir: string): void {
|
||||
const repoRoot = process.env.FUSION_TEST_REAL_ROOT;
|
||||
if (!repoRoot) return;
|
||||
@@ -127,7 +131,7 @@ describe("snapshotDirtyFiles", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "fusion-test-merger-snapshot-"));
|
||||
dir = mkdtempSync(join(testTempParent(), "fusion-test-merger-snapshot-"));
|
||||
createdDirs.add(dir);
|
||||
assertIsolatedWorkspace(dir);
|
||||
initRepo(dir);
|
||||
@@ -177,7 +181,7 @@ describe("snapshotDirtyFiles", () => {
|
||||
});
|
||||
|
||||
it("returns empty set when rootDir is not a git repo (error swallowed)", async () => {
|
||||
const nonRepo = mkdtempSync(join(tmpdir(), "fusion-test-merger-non-repo-"));
|
||||
const nonRepo = mkdtempSync(join(testTempParent(), "fusion-test-merger-non-repo-"));
|
||||
assertIsolatedWorkspace(nonRepo);
|
||||
try {
|
||||
const snapshot = await snapshotDirtyFiles(nonRepo);
|
||||
@@ -193,7 +197,7 @@ describe("commitOrAmendMergeWithFixes — staging allowlist", () => {
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "fusion-test-merger-allowlist-"));
|
||||
dir = mkdtempSync(join(testTempParent(), "fusion-test-merger-allowlist-"));
|
||||
createdDirs.add(dir);
|
||||
assertIsolatedWorkspace(dir);
|
||||
initRepo(dir);
|
||||
@@ -528,7 +532,7 @@ describe("snapshotDirtyFiles — paths with embedded spaces", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "fusion-test-merger-snapshot-spaces-"));
|
||||
dir = mkdtempSync(join(testTempParent(), "fusion-test-merger-snapshot-spaces-"));
|
||||
assertIsolatedWorkspace(dir);
|
||||
initRepo(dir);
|
||||
});
|
||||
@@ -581,7 +585,7 @@ describe("commitOrAmendMergeWithFixes — embedded-space paths round-trip", () =
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "fusion-test-merger-allowlist-spaces-"));
|
||||
dir = mkdtempSync(join(testTempParent(), "fusion-test-merger-allowlist-spaces-"));
|
||||
createdDirs.add(dir);
|
||||
assertIsolatedWorkspace(dir);
|
||||
initRepo(dir);
|
||||
|
||||
Reference in New Issue
Block a user