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:
@@ -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