From a48594f128c189a07d943a7b6f34576372ba298d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 9 Aug 2026 23:21:02 -0700 Subject: [PATCH] FN-8929: dismiss accepted chat attachments Clear staged chat attachments immediately when the server accepts a direct or room message. - Add stream and room delivery acceptance callbacks for attachment cleanup. - Preserve staged files on pre-acceptance failures and block destructive clear commands. - Cover direct, room, mobile, and stream acceptance behavior with tests. Files changed: .changeset/fn-8929-chat-attachment-dismiss.md | 7 ++ docs/dashboard-guide.md | 2 +- .../app/api/__tests__/legacy-chat-stream.test.ts | 34 ++++++ packages/dashboard/app/api/chat/chat.ts | 7 ++ packages/dashboard/app/components/ChatView.tsx | 86 +++++++++------ .../__tests__/ChatView.core-interactions.test.tsx | 117 ++++++++++++++++++++- .../components/__tests__/ChatView.core.test.tsx | 6 +- .../__tests__/ChatView.mobile-render.test.tsx | 21 ++++ .../components/__tests__/ChatView.mobile.test.tsx | 2 +- .../components/__tests__/ChatView.rooms.test.tsx | 64 ++++++++--- .../dashboard/app/hooks/__tests__/useChat.test.ts | 54 ++++++++++ .../app/hooks/__tests__/useChatRooms.test.ts | 33 +++++- packages/dashboard/app/hooks/useChat.ts | 11 +- packages/dashboard/app/hooks/useChatRooms.ts | 13 ++- 14 files changed, 393 insertions(+), 64 deletions(-) Fusion-Task-Id: FN-8929 Fusion-Task-Lineage: 3f39d1aa-a4f9-4a71-a7a8-3522d7c3c83e Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8929-chat-attachment-dismiss.md | 7 ++ docs/dashboard-guide.md | 2 +- .../api/__tests__/legacy-chat-stream.test.ts | 34 +++++ packages/dashboard/app/api/chat/chat.ts | 7 ++ .../dashboard/app/components/ChatView.tsx | 86 ++++++++----- .../ChatView.core-interactions.test.tsx | 117 +++++++++++++++++- .../__tests__/ChatView.core.test.tsx | 6 +- .../__tests__/ChatView.mobile-render.test.tsx | 21 ++++ .../__tests__/ChatView.mobile.test.tsx | 2 +- .../__tests__/ChatView.rooms.test.tsx | 64 +++++++--- .../app/hooks/__tests__/useChat.test.ts | 54 ++++++++ .../app/hooks/__tests__/useChatRooms.test.ts | 33 ++++- packages/dashboard/app/hooks/useChat.ts | 11 +- packages/dashboard/app/hooks/useChatRooms.ts | 13 +- 14 files changed, 393 insertions(+), 64 deletions(-) create mode 100644 .changeset/fn-8929-chat-attachment-dismiss.md diff --git a/.changeset/fn-8929-chat-attachment-dismiss.md b/.changeset/fn-8929-chat-attachment-dismiss.md new file mode 100644 index 0000000000..7cee39c510 --- /dev/null +++ b/.changeset/fn-8929-chat-attachment-dismiss.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Chat attachment thumbnails now clear as soon as the message is accepted. +category: fix +dev: Adds direct-stream acceptance and room delivery callbacks, plus staged-attachment guards for /clear and /new. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index ec9f07c006..a6e1a89a3f 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -727,7 +727,7 @@ Chat view provides project-scoped conversations with agents. - Assistant question tool calls now render as a shared in-chat response card instead of a generic tool-call disclosure. The card recognizes provider-native question tools and Fusion's `fn_ask_question`, supports select, multi-select, text, and yes/no prompts, sends the formatted answer back into the same direct or room thread, and renders historical answered questions read-only. - The desktop Chat view toggle and mobile Chat tab now show an unread-response indicator when a live assistant reply arrives for a visible direct or room chat after you leave Chat; opening Chat clears it immediately. Task-detail planner Chat replies stay task-local and do not light up the global Chat unread indicator while those sessions are hidden from the common Chat feed. - Agent-backed chat sessions now expose the same mailbox messaging tools (`fn_send_message`, `fn_read_messages`) used by runtime execution/heartbeat flows whenever the engine `MessageStore` is available; model-only chats continue to run without mailbox tools. -- Main Chat and Chat Rooms accept the same supported photos and files as Quick Add through the paperclip, clipboard paste, or drag/drop. Pending images retain their preview/open behavior, while non-image files remain removable filename chips; failed sends preserve staged attachments for retry. +- Main Chat and Chat Rooms accept the same supported photos and files as Quick Add through the paperclip, clipboard paste, or drag/drop. Pending images retain their preview/open behavior, while non-image files remain removable filename chips; previews dismiss as soon as the server accepts the turn, while failed pre-acceptance sends preserve staged attachments for retry. Exact `/new` and `/clear` refuse when attachments are staged so unsent files are never silently discarded. - Chat attachments are included in agent-visible prompts for both direct sessions and rooms: supported text attachments are appended under an `Attachments` prompt section, and supported images (`png`, `jpeg`, `gif`, `webp`) are passed as image inputs to the model. - Chat attachments can be sent without accompanying text in both Quick Chat and Main Chat; fully empty sends with no text and no attachments are still blocked. diff --git a/packages/dashboard/app/api/__tests__/legacy-chat-stream.test.ts b/packages/dashboard/app/api/__tests__/legacy-chat-stream.test.ts index 3ab7316e0e..694d772fc7 100644 --- a/packages/dashboard/app/api/__tests__/legacy-chat-stream.test.ts +++ b/packages/dashboard/app/api/__tests__/legacy-chat-stream.test.ts @@ -51,6 +51,40 @@ describe("streamChatResponse SSE parser", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("fires acceptance once before the first stream event", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(createChunkedStream(["event: text\ndata: \"Hello\"\n\nevent: done\ndata: {\"messageId\":\"msg-1\"}\n\n"]), { status: 200 }), + ); + + const events: string[] = []; + streamChatResponse("s-1", "hi", { + onAccepted: () => events.push("accepted"), + onText: () => events.push("text"), + onDone: () => events.push("done"), + }); + + await vi.waitFor(() => expect(events).toEqual(["accepted", "text", "done"])); + }); + + it.each([ + { name: "the response is rejected", result: new Response("no", { status: 500 }) }, + { name: "fetch rejects", result: new Error("network failure") }, + ])("does not accept when $name", async ({ result }) => { + const onAccepted = vi.fn(); + const onError = vi.fn(); + if (result instanceof Error) { + vi.spyOn(globalThis, "fetch").mockRejectedValue(result); + } else { + vi.spyOn(globalThis, "fetch").mockResolvedValue(result); + } + + streamChatResponse("s-1", "hi", { onAccepted, onError }); + + await vi.waitFor(() => expect(onError).toHaveBeenCalled()); + expect(onAccepted).not.toHaveBeenCalled(); + expect(onError.mock.calls[0]?.[1]).toMatchObject({ requestAccepted: false }); + }); + it("flushes terminal done event when stream ends without final newline", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response(createChunkedStream(["event: done\ndata: {\"messageId\":\"msg-tail\"}"]), { status: 200 }), diff --git a/packages/dashboard/app/api/chat/chat.ts b/packages/dashboard/app/api/chat/chat.ts index a7c336276b..3c3a11a999 100644 --- a/packages/dashboard/app/api/chat/chat.ts +++ b/packages/dashboard/app/api/chat/chat.ts @@ -540,6 +540,8 @@ export interface ChatStreamErrorMeta { } export interface ChatStreamHandlers { + /** Fires once when the server accepts this new turn after multipart upload and before stream events. */ + onAccepted?: () => void; onThinking?: (data: string) => void; onText?: (data: string) => void; onToolStart?: (data: { toolName: string; args?: Record }) => void; @@ -683,6 +685,11 @@ export function streamChatResponse( } requestAccepted = true; + /* + FNXC:ChatAttachments 2026-08-10-05:51: + Server acceptance is the earliest point staged files are provably uploaded, so composer previews release here rather than at stream completion. Pre-acceptance failures retain them for retry. + */ + handlers.onAccepted?.(); handlers.onConnectionStateChange?.("connected"); firstEventTimer = setTimeout(() => { if (terminated || closedByUser || receivedStreamEvent) { diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 4a50ebce78..6f6d8f059b 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -1843,6 +1843,22 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout }); }, [activeDraftKey]); + /* + FNXC:ChatAttachments 2026-08-10-05:53: + Composer previews leave only after the server accepts their File set, not after stream or refetch completion. Filtering inside the state updater makes repeated terminal backstops idempotent and preserves files staged after acceptance. + */ + const releaseSentAttachments = useCallback((sentFiles: Set) => { + setPendingAttachments((prev) => { + const released = prev.filter((attachment) => sentFiles.has(attachment.file)); + for (const attachment of released) { + if (attachment.previewUrl) { + URL.revokeObjectURL(attachment.previewUrl); + } + } + return prev.filter((attachment) => !sentFiles.has(attachment.file)); + }); + }, []); + // Handle send message including pending attachment uploads. const handleSend = useCallback(() => { const trimmed = messageInput.trim(); @@ -1896,6 +1912,15 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout } if (trimmed === "/clear" || trimmed === "/new") { + /* + FNXC:ChatSlashCommands 2026-08-10-05:57: + Exact /clear and /new route through clearComposerState(), which revokes staged preview URLs and discards unsent Files. Refuse with feedback, matching command attachment handling, instead of silently destroying them. + */ + if (files.length > 0) { + addToast(t("chat.clearNoAttachments", "Remove the attachments before running /clear or /new — they would be discarded unsent"), "warning"); + return; + } + /* FNXC:ChatSlashCommands 2026-07-23-12:00: `/new`//`/clear` must never wipe a task-bound planner chat. With `showTaskChatsInCommonFeed` @@ -1922,26 +1947,22 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout return; } + if (isStreaming && files.length > 0) { + /* + FNXC:ChatAttachments 2026-08-10-05:53: + Queued direct turns carry text only, so refuse staged attachments during a live reply rather than orphaning previews for files the queue cannot send. + */ + addToast(t("chat.attachmentsNotQueued", "Attachments can't be queued while a reply is streaming — wait for it to finish"), "warning"); + return; + } + const sentFiles = new Set(files); - /* - FNXC:QuickAddAttachments 2026-07-23-00:00: - Keep direct-chat previews alive until useChat confirms multipart delivery. A direct-session - upload fails asynchronously, so clearComposerState() here would revoke the only retryable File - references before its stream error handler can report the rejection. - */ setMessageInput(""); try { sendMessage(trimmed, files, { - onDelivered: () => { - setPendingAttachments((prev) => { - for (const attachment of prev) { - if (sentFiles.has(attachment.file) && attachment.previewUrl) { - URL.revokeObjectURL(attachment.previewUrl); - } - } - return prev.filter((attachment) => !sentFiles.has(attachment.file)); - }); - }, + onAccepted: () => releaseSentAttachments(sentFiles), + // Completion remains an idempotent backstop for accepted provider-error and legacy paths. + onDelivered: () => releaseSentAttachments(sentFiles), onFailed: () => { // Do not overwrite text the user entered while the failed request was in flight. setMessageInput((current) => current || trimmed); @@ -1961,6 +1982,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout addToast, sendMessage, chatCommandContext, + isStreaming, + releaseSentAttachments, t, ]); @@ -1982,6 +2005,14 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout } if (trimmed === "/clear" || trimmed === "/new") { + /* + FNXC:ChatSlashCommands 2026-08-10-05:57: + Room clear/new also revokes and discards pending previews, so reject only exact commands with staged files before touching room state. + */ + if (files.length > 0) { + addToast(t("chat.clearNoAttachments", "Remove the attachments before running /clear or /new — they would be discarded unsent"), "warning"); + return; + } clearComposerState(); try { await rooms.clearRoom(rooms.activeRoom.id); @@ -2003,26 +2034,13 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout setMessageInput(""); try { - await rooms.sendRoomMessage(trimmed, { files }); - setPendingAttachments((prev) => { - for (const attachment of prev) { - if (sentFiles.has(attachment.file) && attachment.previewUrl) { - URL.revokeObjectURL(attachment.previewUrl); - } - } - return prev.filter((attachment) => !sentFiles.has(attachment.file)); - }); + await rooms.sendRoomMessage(trimmed, { files, onDelivered: () => releaseSentAttachments(sentFiles) }); + // Refetch completion is an idempotent backstop after delivery acceptance. + releaseSentAttachments(sentFiles); } catch (error) { if (error instanceof RoomMessageDeliveredButReplyFailedError) { // The server accepted this turn, so release only the attachments that were dispatched. - setPendingAttachments((prev) => { - for (const attachment of prev) { - if (sentFiles.has(attachment.file) && attachment.previewUrl) { - URL.revokeObjectURL(attachment.previewUrl); - } - } - return prev.filter((attachment) => !sentFiles.has(attachment.file)); - }); + releaseSentAttachments(sentFiles); const message = error.message.trim() ? error.message : t("chat.messageSentButReplyFailed", "Message sent, but assistant reply failed"); @@ -2042,7 +2060,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout } handleSend(); - }, [messageInput, pendingAttachments, chatRoomsEnabled, chatScope, rooms, rooms.clearRoom, clearComposerState, addToast, handleSend]); + }, [messageInput, pendingAttachments, chatRoomsEnabled, chatScope, rooms, rooms.clearRoom, clearComposerState, addToast, handleSend, releaseSentAttachments]); const handleQuestionSubmit = useCallback(async (answerText: string) => { if (chatRoomsEnabled && chatScope === "rooms") { diff --git a/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx index d8bf5ad0a8..3734fef0c2 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx @@ -151,9 +151,9 @@ describe("ChatView core interactions", () => { expect(clickSpy).toHaveBeenCalled(); }); - it("allows attaching an image and clears it after attachment-only delivery", async () => { - const sendMessage = vi.fn((_content: string, _files?: File[], callbacks?: { onDelivered?: () => void }) => { - callbacks?.onDelivered?.(); + it("dismisses image previews at acceptance before stream delivery", async () => { + const sendMessage = vi.fn((_content: string, _files?: File[], callbacks?: { onAccepted?: () => void }) => { + callbacks?.onAccepted?.(); }); setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage }); await renderWithAct(); @@ -171,12 +171,123 @@ describe("ChatView core interactions", () => { await userEvent.click(sendButton); expect(sendMessage).toHaveBeenCalledWith("", [imageFile], expect.objectContaining({ + onAccepted: expect.any(Function), onDelivered: expect.any(Function), onFailed: expect.any(Function), })); await waitFor(() => { expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument(); + expect(screen.queryByTestId("chat-attachment-preview-0")).not.toBeInTheDocument(); }); + expect(mockRevokeObjectURL).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("chat-attach-btn")).toBeInTheDocument(); + }); + + it("releases every accepted file, including a filename-only preview", async () => { + const sendMessage = vi.fn((_content: string, _files?: File[], callbacks?: { onAccepted?: () => void }) => { + callbacks?.onAccepted?.(); + }); + setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage }); + await renderWithAct(); + + const imageFile = new File(["image"], "accepted.png", { type: "image/png" }); + const textFile = new File(["note"], "accepted.txt", { type: "text/plain" }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [imageFile, textFile] } }); + expect(await screen.findByText("accepted.txt")).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId("chat-send-btn")); + + await waitFor(() => { + expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument(); + expect(screen.queryByAltText("accepted.png")).not.toBeInTheDocument(); + expect(screen.queryByText("accepted.txt")).not.toBeInTheDocument(); + }); + expect(mockRevokeObjectURL).toHaveBeenCalledTimes(1); + expect(mockRevokeObjectURL).toHaveBeenCalledWith("blob:accepted.png"); + }); + + it("retains attachments staged after a direct send while its acceptance is pending", async () => { + let onAccepted: (() => void) | undefined; + const sendMessage = vi.fn((_content: string, _files?: File[], callbacks?: { onAccepted?: () => void }) => { + onAccepted = callbacks?.onAccepted; + }); + setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage }); + await renderWithAct(); + + const acceptedFile = new File(["accepted"], "accepted.png", { type: "image/png" }); + const stagedLaterFile = new File(["later"], "later.png", { type: "image/png" }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [acceptedFile] } }); + await userEvent.click(screen.getByTestId("chat-send-btn")); + expect(onAccepted).toEqual(expect.any(Function)); + + fireEvent.change(fileInput, { target: { files: [stagedLaterFile] } }); + expect(await screen.findByAltText("later.png")).toBeInTheDocument(); + await act(async () => onAccepted?.()); + + await waitFor(() => { + expect(screen.queryByAltText("accepted.png")).not.toBeInTheDocument(); + expect(screen.getByAltText("later.png")).toBeInTheDocument(); + }); + expect(mockRevokeObjectURL).toHaveBeenCalledTimes(1); + expect(mockRevokeObjectURL).toHaveBeenCalledWith("blob:accepted.png"); + expect(mockRevokeObjectURL).not.toHaveBeenCalledWith("blob:later.png"); + }); + + it("refuses staged attachments while a direct reply is streaming", async () => { + const sendMessage = vi.fn(); + const addToast = vi.fn(); + setupMockChat({ activeSession: activeSessionFixture, messages: [], isStreaming: true, sendMessage }); + await renderWithAct(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const imageFile = new File(["image"], "queued.png", { type: "image/png" }); + fireEvent.change(fileInput, { target: { files: [imageFile] } }); + fireEvent.keyDown(screen.getByTestId("chat-input"), { key: "Enter" }); + + expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Attachments can't be queued"), "warning"); + expect(sendMessage).not.toHaveBeenCalled(); + expect(screen.getByTestId("chat-attachment-previews")).toBeInTheDocument(); + }); + + it.each(["/clear", "/new"])("refuses %s while attachments are staged", async (command) => { + const createSession = vi.fn(); + const sendMessage = vi.fn(); + const addToast = vi.fn(); + setupMockChat({ activeSession: activeSessionFixture, messages: [], createSession, sendMessage }); + await renderWithAct(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [new File(["image"], "guarded.png", { type: "image/png" })] } }); + await userEvent.type(screen.getByTestId("chat-input"), `${command}{enter}`); + + expect(screen.getByTestId("chat-attachment-previews")).toBeInTheDocument(); + expect(mockRevokeObjectURL).not.toHaveBeenCalled(); + expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Remove the attachments"), "warning"); + expect(createSession).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it.each(["/clear", "/new"])("refuses %s in a task-planner session before its command handler clears attachments", async (command) => { + const addToast = vi.fn(); + const createSession = vi.fn(); + setupMockChat({ + activeSession: { ...activeSessionFixture, agentId: "task-planner:task-123" }, + messages: [], + createSession, + }); + await renderWithAct(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [new File(["image"], "planner.png", { type: "image/png" })] } }); + await userEvent.type(screen.getByTestId("chat-input"), `${command}{enter}`); + + expect(screen.getByTestId("chat-attachment-previews")).toBeInTheDocument(); + expect(mockRevokeObjectURL).not.toHaveBeenCalled(); + expect(createSession).not.toHaveBeenCalled(); + expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Remove the attachments"), "warning"); + expect(addToast).not.toHaveBeenCalledWith(expect.stringContaining("tied to a task"), "warning"); }); it("retains direct-chat attachments after a failed upload for retry", async () => { diff --git a/packages/dashboard/app/components/__tests__/ChatView.core.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.core.test.tsx index 20140d56d9..6d93704d1f 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.core.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.core.test.tsx @@ -1499,7 +1499,7 @@ describe("ChatView", () => { await userEvent.type(textarea, "Room hello{enter}"); await waitFor(() => { - expect(sendRoomMessage).toHaveBeenCalledWith("Room hello", { files: [] }); + expect(sendRoomMessage).toHaveBeenCalledWith("Room hello", expect.objectContaining({ files: [] })); }); expect(textarea.value).toBe(""); localStorage.removeItem("fusion:chat-scope"); @@ -1528,7 +1528,7 @@ describe("ChatView", () => { await userEvent.click(screen.getByTestId("chat-send-btn")); await waitFor(() => { - expect(sendRoomMessage).toHaveBeenCalledWith("Room click hello", { files: [] }); + expect(sendRoomMessage).toHaveBeenCalledWith("Room click hello", expect.objectContaining({ files: [] })); }); expect(sendMessage).not.toHaveBeenCalled(); expect(textarea.value).toBe(""); @@ -1565,7 +1565,7 @@ describe("ChatView", () => { await userEvent.click(sendButton); await waitFor(() => { - expect(sendRoomMessage).toHaveBeenCalledWith("", { files: [textFile] }); + expect(sendRoomMessage).toHaveBeenCalledWith("", expect.objectContaining({ files: [textFile] })); }); expect(sendMessage).not.toHaveBeenCalled(); expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument(); diff --git a/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx index 9c1610c824..6edc2c460c 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx @@ -218,6 +218,27 @@ describe("FN-5997 mobile chat message pane rendering", () => { } }); + it("dismisses mobile attachment previews at stream acceptance", async () => { + const restoreMatchMedia = mockViewportMode("mobile"); + try { + const sendMessage = vi.fn((_content: string, _files?: File[], callbacks?: { onAccepted?: () => void }) => callbacks?.onAccepted?.()); + setupChat({ sessions: [activeSession], filteredSessions: [activeSession], activeSession, sendMessage }); + await renderWithCss(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [new File(["note"], "mobile.txt", { type: "text/plain" })] } }); + expect(await screen.findByTestId("chat-attachment-previews")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("chat-send-btn")); + await waitFor(() => expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument()); + expect(screen.queryByTestId("chat-attachment-remove-0")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Remove mobile.txt" })).not.toBeInTheDocument(); + expect(screen.getByTestId("chat-attach-btn")).toBeInTheDocument(); + } finally { + restoreMatchMedia.mockRestore(); + } + }); + it("lets the direct-thread mobile empty states span the message pane without mobile card chrome while preserving other states", async () => { const restoreMatchMedia = mockViewportMode("mobile"); try { diff --git a/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx index 83f1429e27..771eb64201 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx @@ -1009,7 +1009,7 @@ describe("ChatView mobile behavior", () => { await waitFor(() => { expect(sendRoomMessage).toHaveBeenCalledTimes(1); - expect(sendRoomMessage).toHaveBeenCalledWith("Hello mobile room", { files: [] }); + expect(sendRoomMessage).toHaveBeenCalledWith("Hello mobile room", expect.objectContaining({ files: [] })); }); expect(sendMessage).not.toHaveBeenCalled(); expect(document.activeElement).toBe(input); diff --git a/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx index e9906308e7..42745c6c64 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx @@ -386,7 +386,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { await userEvent.type(textarea, "Hello room{enter}"); await waitFor(() => { - expect(sendRoomMessage).toHaveBeenCalledWith("Hello room", { files: [] }); + expect(sendRoomMessage).toHaveBeenCalledWith("Hello room", expect.objectContaining({ files: [] })); }); await waitFor(() => { expect((textarea as HTMLTextAreaElement).value).toBe(""); @@ -481,7 +481,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { await userEvent.type(textarea, "Room upload{enter}"); await waitFor(() => { - expect(sendRoomMessage).toHaveBeenCalledWith("Room upload", { files: [] }); + expect(sendRoomMessage).toHaveBeenCalledWith("Room upload", expect.objectContaining({ files: [] })); }); expect(addToast).not.toHaveBeenCalledWith(expect.stringMatching(/attach/i), "warning"); }); @@ -504,7 +504,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { await waitFor(() => { expect(sendRoomMessage).toHaveBeenCalledTimes(1); - expect(sendRoomMessage).toHaveBeenCalledWith("single send", { files: [] }); + expect(sendRoomMessage).toHaveBeenCalledWith("single send", expect.objectContaining({ files: [] })); }); resolveSend!(); @@ -526,7 +526,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { await userEvent.type(textarea, "Will retry{enter}"); await waitFor(() => { - expect(sendRoomMessage).toHaveBeenCalledWith("Will retry", { files: [] }); + expect(sendRoomMessage).toHaveBeenCalledWith("Will retry", expect.objectContaining({ files: [] })); }); await waitFor(() => { expect(textarea.value).toBe(""); @@ -545,7 +545,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { await userEvent.type(textarea, "Will retry{enter}"); await waitFor(() => { - expect(sendRoomMessage).toHaveBeenCalledWith("Will retry", { files: [] }); + expect(sendRoomMessage).toHaveBeenCalledWith("Will retry", expect.objectContaining({ files: [] })); }); await waitFor(() => { expect(textarea.value).toBe("Will retry"); @@ -565,11 +565,29 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; await userEvent.type(textarea, "Retry attachment{enter}"); - await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledWith("Retry attachment", { files: [file] })); + await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledWith("Retry attachment", expect.objectContaining({ files: [file] }))); await waitFor(() => expect(textarea.value).toBe("Retry attachment")); expect(screen.getByText("retry.txt")).toBeInTheDocument(); }); + it("dismisses room previews on delivery before the room send settles", async () => { + const sendRoomMessage = vi.fn((_content: string, opts?: { onDelivered?: () => void }) => { + opts?.onDelivered?.(); + return new Promise(() => {}); + }); + setup({}, { sendRoomMessage, activeRoom: roomA }); + + await renderWithAct(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [new File(["note"], "delivered.txt", { type: "text/plain" })] } }); + fireEvent.click(screen.getByTestId("chat-send-btn")); + + await waitFor(() => expect(sendRoomMessage).toHaveBeenCalled()); + await waitFor(() => expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument()); + expect(screen.queryByTestId("chat-attachment-preview-0")).not.toBeInTheDocument(); + }); + it("clears room composer on Enter when room send succeeds", async () => { const sendRoomMessage = vi.fn().mockResolvedValue(undefined); setup({}, { sendRoomMessage, activeRoom: roomA }); @@ -580,7 +598,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { await userEvent.type(textarea, "Delivered{enter}"); await waitFor(() => { - expect(sendRoomMessage).toHaveBeenCalledWith("Delivered", { files: [] }); + expect(sendRoomMessage).toHaveBeenCalledWith("Delivered", expect.objectContaining({ files: [] })); }); await waitFor(() => { expect(textarea.value).toBe(""); @@ -601,7 +619,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { await userEvent.type(textarea, "Optimistic clear{enter}"); await waitFor(() => { - expect(sendRoomMessage).toHaveBeenCalledWith("Optimistic clear", { files: [] }); + expect(sendRoomMessage).toHaveBeenCalledWith("Optimistic clear", expect.objectContaining({ files: [] })); }); expect(textarea.value).toBe(""); @@ -644,6 +662,24 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { expect(sendRoomMessage).not.toHaveBeenCalled(); }); + it.each(["/clear", "/new"])("refuses %s with staged room attachments", async (command) => { + const addToast = vi.fn(); + const clearRoom = vi.fn().mockResolvedValue(undefined); + const sendRoomMessage = vi.fn().mockResolvedValue(undefined); + setup({}, { clearRoom, sendRoomMessage, activeRoom: roomA }); + + await renderWithAct(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [new File(["file"], "guarded.txt", { type: "text/plain" })] } }); + await userEvent.type(screen.getByTestId("chat-input"), `${command}{enter}`); + + expect(screen.getByTestId("chat-attachment-previews")).toBeInTheDocument(); + expect(clearRoom).not.toHaveBeenCalled(); + expect(sendRoomMessage).not.toHaveBeenCalled(); + expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Remove the attachments"), "warning"); + }); + it("does not intercept /clear substring commands in rooms scope", async () => { const clearRoom = vi.fn().mockResolvedValue(undefined); const sendRoomMessage = vi.fn().mockResolvedValue(undefined); @@ -655,7 +691,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { await userEvent.type(textarea, "/clear now{enter}"); await waitFor(() => { - expect(sendRoomMessage).toHaveBeenCalledWith("/clear now", { files: [] }); + expect(sendRoomMessage).toHaveBeenCalledWith("/clear now", expect.objectContaining({ files: [] })); }); expect(clearRoom).not.toHaveBeenCalled(); }); @@ -773,7 +809,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { }); await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledTimes(1)); - expect(sendRoomMessage).toHaveBeenCalledWith("Room iOS tap", { files: [] }); + expect(sendRoomMessage).toHaveBeenCalledWith("Room iOS tap", expect.objectContaining({ files: [] })); mediaSpy.mockRestore(); }); @@ -791,7 +827,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { firstSendButton.dispatchEvent(Object.assign(new Event("pointerdown", { bubbles: true, cancelable: true }), { pointerType: "touch" })); }); await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledTimes(1)); - expect(sendRoomMessage).toHaveBeenLastCalledWith("Room first", { files: [] }); + expect(sendRoomMessage).toHaveBeenLastCalledWith("Room first", expect.objectContaining({ files: [] })); await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, 0)); }); @@ -803,7 +839,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { }); await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledTimes(2)); - expect(sendRoomMessage).toHaveBeenLastCalledWith("Room second", { files: [] }); + expect(sendRoomMessage).toHaveBeenLastCalledWith("Room second", expect.objectContaining({ files: [] })); mediaSpy.mockRestore(); }); @@ -823,7 +859,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { }); await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledTimes(1)); - expect(sendRoomMessage).toHaveBeenCalledWith("Room Android tap", { files: [] }); + expect(sendRoomMessage).toHaveBeenCalledWith("Room Android tap", expect.objectContaining({ files: [] })); mediaSpy.mockRestore(); }); @@ -843,7 +879,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { }); await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledTimes(1)); - expect(sendRoomMessage).toHaveBeenCalledWith("Room desktop tap", { files: [] }); + expect(sendRoomMessage).toHaveBeenCalledWith("Room desktop tap", expect.objectContaining({ files: [] })); mediaSpy.mockRestore(); }); diff --git a/packages/dashboard/app/hooks/__tests__/useChat.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.test.ts index 48ffbd60e8..c223b5e521 100644 --- a/packages/dashboard/app/hooks/__tests__/useChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChat.test.ts @@ -5088,6 +5088,60 @@ describe("useChat", () => { }); }); + it("forwards stream acceptance but retains delivery fallback on accepted provider errors", async () => { + mockFetchChatSessions.mockResolvedValueOnce({ + sessions: [makeSession({ id: "session-001", agentId: "agent-001" })], + }); + mockFetchChatMessages.mockResolvedValueOnce({ messages: [] }); + let acceptedHandler: (() => void) | undefined; + let errorHandler: ((data: string | apiModule.ChatFailureInfo, meta?: apiModule.ChatStreamErrorMeta) => void) | undefined; + mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { + acceptedHandler = handlers.onAccepted; + errorHandler = handlers.onError; + return { close: vi.fn(), isConnected: () => true }; + }); + + const { result } = renderHook(() => useChat("proj-123")); + await waitFor(() => expect(result.current.sessions).toHaveLength(1)); + act(() => result.current.selectSession("session-001")); + await waitFor(() => expect(result.current.activeSession?.id).toBe("session-001")); + + const onAccepted = vi.fn(); + const onDelivered = vi.fn(); + const onFailed = vi.fn(); + act(() => result.current.sendMessage("Hello!", undefined, { onAccepted, onDelivered, onFailed })); + act(() => acceptedHandler?.()); + expect(onAccepted).toHaveBeenCalledTimes(1); + + act(() => errorHandler?.("Provider failed", { requestAccepted: true, receivedStreamEvent: true })); + expect(onDelivered).toHaveBeenCalledTimes(1); + expect(onFailed).not.toHaveBeenCalled(); + }); + + it("does not accept when the stream reports a pre-acceptance error", async () => { + mockFetchChatSessions.mockResolvedValueOnce({ + sessions: [makeSession({ id: "session-001", agentId: "agent-001" })], + }); + mockFetchChatMessages.mockResolvedValueOnce({ messages: [] }); + let errorHandler: ((data: string | apiModule.ChatFailureInfo, meta?: apiModule.ChatStreamErrorMeta) => void) | undefined; + mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { + errorHandler = handlers.onError; + return { close: vi.fn(), isConnected: () => true }; + }); + + const { result } = renderHook(() => useChat("proj-123")); + await waitFor(() => expect(result.current.sessions).toHaveLength(1)); + act(() => result.current.selectSession("session-001")); + await waitFor(() => expect(result.current.activeSession?.id).toBe("session-001")); + + const onAccepted = vi.fn(); + const onFailed = vi.fn(); + act(() => result.current.sendMessage("Hello!", undefined, { onAccepted, onFailed })); + act(() => errorHandler?.("Request failed", { requestAccepted: false, receivedStreamEvent: false })); + expect(onAccepted).not.toHaveBeenCalled(); + expect(onFailed).toHaveBeenCalledTimes(1); + }); + it("keeps accepted sent message visible after provider error and reconciles persisted echo", async () => { mockFetchChatSessions.mockResolvedValueOnce({ sessions: [makeSession({ id: "session-001", agentId: "agent-001" })], diff --git a/packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts b/packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts index a7a6762936..296fc70d29 100644 --- a/packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts @@ -445,6 +445,31 @@ describe("useChatRooms", () => { }, "proj-1"); }); + it("signals delivery before the trailing refetch and isolates throwing consumers", 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")); + + let resolveRefetch: ((value: { messages: ChatRoomMessage[] }) => void) | undefined; + mockFetchChatRoomMessages.mockReturnValueOnce(new Promise((resolve) => { resolveRefetch = resolve; })); + const onDelivered = vi.fn(() => { throw new Error("consumer failure"); }); + let sendPromise!: Promise; + await act(async () => { + sendPromise = result.current.sendRoomMessage("hello", { onDelivered }); + }); + + await waitFor(() => expect(onDelivered).toHaveBeenCalledTimes(1)); + expect(mockPostChatRoomMessage).toHaveBeenCalledTimes(1); + resolveRefetch?.({ messages: [roomMessage("msg-user", "room-1", "hello")] }); + await expect(sendPromise).resolves.toBeUndefined(); + }); + it("throws on upload failure and does not post", async () => { const active = room("room-1", "one", "2026-05-09T01:00:00.000Z"); mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] }); @@ -457,13 +482,14 @@ describe("useChatRooms", () => { await waitFor(() => expect(result.current.activeRoom?.id).toBe("room-1")); const file = new File(["x"], "bad.txt", { type: "text/plain" }); + const onDelivered = vi.fn(); mockUploadChatRoomAttachment.mockRejectedValueOnce(new Error("Upload failed")); mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: [] }); let uploadError: unknown; await act(async () => { try { - await result.current.sendRoomMessage("hello", { files: [file] }); + await result.current.sendRoomMessage("hello", { files: [file], onDelivered }); } catch (error) { uploadError = error; } @@ -472,6 +498,7 @@ describe("useChatRooms", () => { expect(uploadError).toBeInstanceOf(Error); expect((uploadError as Error).message).toBe("Failed to upload attachment: bad.txt"); expect(uploadError).not.toBeInstanceOf(RoomMessageDeliveredButReplyFailedError); + expect(onDelivered).not.toHaveBeenCalled(); expect(mockPostChatRoomMessage).not.toHaveBeenCalledWith("room-1", expect.objectContaining({ content: "hello" }), "proj-1"); }); @@ -492,6 +519,7 @@ describe("useChatRooms", () => { role: "assistant" as const, senderAgentId: "agent-1", }; + const onDelivered = vi.fn(); mockPostChatRoomMessage.mockRejectedValueOnce(new Error("POST failed")); mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: [recoveredAssistantReply], @@ -500,7 +528,7 @@ describe("useChatRooms", () => { let postError: unknown; await act(async () => { try { - await result.current.sendRoomMessage("hello"); + await result.current.sendRoomMessage("hello", { onDelivered }); } catch (error) { postError = error; } @@ -509,6 +537,7 @@ describe("useChatRooms", () => { expect(postError).toBeInstanceOf(Error); expect((postError as Error).message).toBe("POST failed"); expect(postError).not.toBeInstanceOf(RoomMessageDeliveredButReplyFailedError); + expect(onDelivered).not.toHaveBeenCalled(); expect(result.current.messages).toEqual([recoveredAssistantReply]); }); diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index 9e0943ad6c..c791f6a67c 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -165,7 +165,7 @@ export interface UseChatReturn { sendMessage: ( content: string, attachments?: File[], - callbacks?: { onDelivered?: () => void; onFailed?: () => void }, + callbacks?: { onAccepted?: () => void; onDelivered?: () => void; onFailed?: () => void }, ) => void; /** * FNXC:ChatMessageEdit 2026-07-07-09:00: @@ -1392,7 +1392,7 @@ export function useChat( const sendMessageRef = useRef<( content: string, attachments?: File[], - callbacks?: { onDelivered?: () => void; onFailed?: () => void }, + callbacks?: { onAccepted?: () => void; onDelivered?: () => void; onFailed?: () => void }, ) => void>(() => { // no-op until sendMessage is defined }); @@ -1439,7 +1439,7 @@ export function useChat( ( content: string, attachments?: File[], - callbacks?: { onDelivered?: () => void; onFailed?: () => void }, + callbacks?: { onAccepted?: () => void; onDelivered?: () => void; onFailed?: () => void }, ) => { if (!activeSession) { callbacks?.onFailed?.(); @@ -1626,7 +1626,10 @@ export function useChat( }, }); - streamRef.current = streamChatResponse(activeSession.id, content, handlers, attachments, projectId); + streamRef.current = streamChatResponse(activeSession.id, content, { + ...handlers, + onAccepted: () => callbacks?.onAccepted?.(), + }, attachments, projectId); }, [activeSession, projectId, refreshSessions, addToast, attachIfGenerating, reconnectSessionSilently, flushPendingMessage], ); diff --git a/packages/dashboard/app/hooks/useChatRooms.ts b/packages/dashboard/app/hooks/useChatRooms.ts index 87ee189584..437effdd4d 100644 --- a/packages/dashboard/app/hooks/useChatRooms.ts +++ b/packages/dashboard/app/hooks/useChatRooms.ts @@ -49,7 +49,7 @@ export interface UseChatRoomsResult { createRoom: (input: { name: string; memberAgentIds: string[] }) => Promise; updateRoomSettings: (roomId: string, updates: { thinkingLevel?: string | null }) => Promise; deleteRoom: (roomId: string) => Promise; - sendRoomMessage: (content: string, opts?: { attachments?: ChatAttachment[]; files?: File[] }) => Promise; + sendRoomMessage: (content: string, opts?: { attachments?: ChatAttachment[]; files?: File[]; onDelivered?: () => void }) => Promise; clearRoom: (roomId: string) => Promise; refreshRooms: () => Promise; } @@ -340,7 +340,7 @@ export function useChatRooms( * FNXC:RoomChatReliability 2026-07-01-00:00: * Responder/provider failures can occur after the room user message is persisted. Keep the optimistic or recovered user row visible for delivered sends even when the reply-generation or refresh step fails, because that turn is already part of the room transcript context. */ - const sendRoomMessage = useCallback(async (content: string, opts?: { attachments?: ChatAttachment[]; files?: File[] }) => { + const sendRoomMessage = useCallback(async (content: string, opts?: { attachments?: ChatAttachment[]; files?: File[]; onDelivered?: () => void }) => { const activeRoomSnapshot = activeRoomRef.current; const roomId = activeRoomSnapshot?.id; if (!roomId) { @@ -389,6 +389,15 @@ export function useChatRooms( ...(mergedAttachments.length ? { attachments: mergedAttachments } : {}), }, projectId); userMessageDelivered = true; + /* + FNXC:ChatRooms 2026-08-10-05:53: + Delivery is signalled immediately after the post accepts every uploaded file, before reconciliation and refetch, so composer previews dismiss promptly. Isolate consumers so their callback cannot corrupt the delivered-versus-undelivered error contract. + */ + try { + opts?.onDelivered?.(); + } catch { + // Consumer callbacks must not turn an accepted room turn into a delivery failure. + } if (postResult.message?.createdAt && activeRoomSnapshot) { setRooms((previous) => upsertRoom(previous, { ...activeRoomSnapshot, updatedAt: postResult.message.createdAt }));