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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8929-chat-attachment-dismiss.md
Normal file
7
.changeset/fn-8929-chat-attachment-dismiss.md
Normal file
@@ -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.
|
||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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 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.
|
- 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.
|
||||||
<!-- FNXC:ChatMessageEdit 2026-07-12-23:20: Document the message-edit affordance and its resume-from-edit ("forget everything after") semantics, including the model-loop-only scope and inline timestamp placement. -->
|
<!-- FNXC:ChatMessageEdit 2026-07-12-23:20: Document the message-edit affordance and its resume-from-edit ("forget everything after") semantics, including the model-loop-only scope and inline timestamp placement. -->
|
||||||
|
|||||||
@@ -51,6 +51,40 @@ describe("streamChatResponse SSE parser", () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("flushes terminal done event when stream ends without final newline", async () => {
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
new Response(createChunkedStream(["event: done\ndata: {\"messageId\":\"msg-tail\"}"]), { status: 200 }),
|
new Response(createChunkedStream(["event: done\ndata: {\"messageId\":\"msg-tail\"}"]), { status: 200 }),
|
||||||
|
|||||||
@@ -540,6 +540,8 @@ export interface ChatStreamErrorMeta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatStreamHandlers {
|
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;
|
onThinking?: (data: string) => void;
|
||||||
onText?: (data: string) => void;
|
onText?: (data: string) => void;
|
||||||
onToolStart?: (data: { toolName: string; args?: Record<string, unknown> }) => void;
|
onToolStart?: (data: { toolName: string; args?: Record<string, unknown> }) => void;
|
||||||
@@ -683,6 +685,11 @@ export function streamChatResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
requestAccepted = true;
|
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");
|
handlers.onConnectionStateChange?.("connected");
|
||||||
firstEventTimer = setTimeout(() => {
|
firstEventTimer = setTimeout(() => {
|
||||||
if (terminated || closedByUser || receivedStreamEvent) {
|
if (terminated || closedByUser || receivedStreamEvent) {
|
||||||
|
|||||||
@@ -1843,6 +1843,22 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
});
|
});
|
||||||
}, [activeDraftKey]);
|
}, [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<File>) => {
|
||||||
|
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.
|
// Handle send message including pending attachment uploads.
|
||||||
const handleSend = useCallback(() => {
|
const handleSend = useCallback(() => {
|
||||||
const trimmed = messageInput.trim();
|
const trimmed = messageInput.trim();
|
||||||
@@ -1896,6 +1912,15 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (trimmed === "/clear" || trimmed === "/new") {
|
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:
|
FNXC:ChatSlashCommands 2026-07-23-12:00:
|
||||||
`/new`//`/clear` must never wipe a task-bound planner chat. With `showTaskChatsInCommonFeed`
|
`/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;
|
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);
|
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("");
|
setMessageInput("");
|
||||||
try {
|
try {
|
||||||
sendMessage(trimmed, files, {
|
sendMessage(trimmed, files, {
|
||||||
onDelivered: () => {
|
onAccepted: () => releaseSentAttachments(sentFiles),
|
||||||
setPendingAttachments((prev) => {
|
// Completion remains an idempotent backstop for accepted provider-error and legacy paths.
|
||||||
for (const attachment of prev) {
|
onDelivered: () => releaseSentAttachments(sentFiles),
|
||||||
if (sentFiles.has(attachment.file) && attachment.previewUrl) {
|
|
||||||
URL.revokeObjectURL(attachment.previewUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return prev.filter((attachment) => !sentFiles.has(attachment.file));
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onFailed: () => {
|
onFailed: () => {
|
||||||
// Do not overwrite text the user entered while the failed request was in flight.
|
// Do not overwrite text the user entered while the failed request was in flight.
|
||||||
setMessageInput((current) => current || trimmed);
|
setMessageInput((current) => current || trimmed);
|
||||||
@@ -1961,6 +1982,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
addToast,
|
addToast,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
chatCommandContext,
|
chatCommandContext,
|
||||||
|
isStreaming,
|
||||||
|
releaseSentAttachments,
|
||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1982,6 +2005,14 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (trimmed === "/clear" || trimmed === "/new") {
|
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();
|
clearComposerState();
|
||||||
try {
|
try {
|
||||||
await rooms.clearRoom(rooms.activeRoom.id);
|
await rooms.clearRoom(rooms.activeRoom.id);
|
||||||
@@ -2003,26 +2034,13 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
setMessageInput("");
|
setMessageInput("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await rooms.sendRoomMessage(trimmed, { files });
|
await rooms.sendRoomMessage(trimmed, { files, onDelivered: () => releaseSentAttachments(sentFiles) });
|
||||||
setPendingAttachments((prev) => {
|
// Refetch completion is an idempotent backstop after delivery acceptance.
|
||||||
for (const attachment of prev) {
|
releaseSentAttachments(sentFiles);
|
||||||
if (sentFiles.has(attachment.file) && attachment.previewUrl) {
|
|
||||||
URL.revokeObjectURL(attachment.previewUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return prev.filter((attachment) => !sentFiles.has(attachment.file));
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof RoomMessageDeliveredButReplyFailedError) {
|
if (error instanceof RoomMessageDeliveredButReplyFailedError) {
|
||||||
// The server accepted this turn, so release only the attachments that were dispatched.
|
// The server accepted this turn, so release only the attachments that were dispatched.
|
||||||
setPendingAttachments((prev) => {
|
releaseSentAttachments(sentFiles);
|
||||||
for (const attachment of prev) {
|
|
||||||
if (sentFiles.has(attachment.file) && attachment.previewUrl) {
|
|
||||||
URL.revokeObjectURL(attachment.previewUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return prev.filter((attachment) => !sentFiles.has(attachment.file));
|
|
||||||
});
|
|
||||||
const message = error.message.trim()
|
const message = error.message.trim()
|
||||||
? error.message
|
? error.message
|
||||||
: t("chat.messageSentButReplyFailed", "Message sent, but assistant reply failed");
|
: t("chat.messageSentButReplyFailed", "Message sent, but assistant reply failed");
|
||||||
@@ -2042,7 +2060,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleSend();
|
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) => {
|
const handleQuestionSubmit = useCallback(async (answerText: string) => {
|
||||||
if (chatRoomsEnabled && chatScope === "rooms") {
|
if (chatRoomsEnabled && chatScope === "rooms") {
|
||||||
|
|||||||
@@ -151,9 +151,9 @@ describe("ChatView core interactions", () => {
|
|||||||
expect(clickSpy).toHaveBeenCalled();
|
expect(clickSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows attaching an image and clears it after attachment-only delivery", async () => {
|
it("dismisses image previews at acceptance before stream delivery", async () => {
|
||||||
const sendMessage = vi.fn((_content: string, _files?: File[], callbacks?: { onDelivered?: () => void }) => {
|
const sendMessage = vi.fn((_content: string, _files?: File[], callbacks?: { onAccepted?: () => void }) => {
|
||||||
callbacks?.onDelivered?.();
|
callbacks?.onAccepted?.();
|
||||||
});
|
});
|
||||||
setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage });
|
setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage });
|
||||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
@@ -171,12 +171,123 @@ describe("ChatView core interactions", () => {
|
|||||||
|
|
||||||
await userEvent.click(sendButton);
|
await userEvent.click(sendButton);
|
||||||
expect(sendMessage).toHaveBeenCalledWith("", [imageFile], expect.objectContaining({
|
expect(sendMessage).toHaveBeenCalledWith("", [imageFile], expect.objectContaining({
|
||||||
|
onAccepted: expect.any(Function),
|
||||||
onDelivered: expect.any(Function),
|
onDelivered: expect.any(Function),
|
||||||
onFailed: expect.any(Function),
|
onFailed: expect.any(Function),
|
||||||
}));
|
}));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument();
|
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
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(<ChatView projectId="proj-123" addToast={addToast} />);
|
||||||
|
|
||||||
|
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(<ChatView projectId="proj-123" addToast={addToast} />);
|
||||||
|
|
||||||
|
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(<ChatView projectId="proj-123" addToast={addToast} />);
|
||||||
|
|
||||||
|
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 () => {
|
it("retains direct-chat attachments after a failed upload for retry", async () => {
|
||||||
|
|||||||
@@ -1499,7 +1499,7 @@ describe("ChatView", () => {
|
|||||||
await userEvent.type(textarea, "Room hello{enter}");
|
await userEvent.type(textarea, "Room hello{enter}");
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(sendRoomMessage).toHaveBeenCalledWith("Room hello", { files: [] });
|
expect(sendRoomMessage).toHaveBeenCalledWith("Room hello", expect.objectContaining({ files: [] }));
|
||||||
});
|
});
|
||||||
expect(textarea.value).toBe("");
|
expect(textarea.value).toBe("");
|
||||||
localStorage.removeItem("fusion:chat-scope");
|
localStorage.removeItem("fusion:chat-scope");
|
||||||
@@ -1528,7 +1528,7 @@ describe("ChatView", () => {
|
|||||||
await userEvent.click(screen.getByTestId("chat-send-btn"));
|
await userEvent.click(screen.getByTestId("chat-send-btn"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(sendRoomMessage).toHaveBeenCalledWith("Room click hello", { files: [] });
|
expect(sendRoomMessage).toHaveBeenCalledWith("Room click hello", expect.objectContaining({ files: [] }));
|
||||||
});
|
});
|
||||||
expect(sendMessage).not.toHaveBeenCalled();
|
expect(sendMessage).not.toHaveBeenCalled();
|
||||||
expect(textarea.value).toBe("");
|
expect(textarea.value).toBe("");
|
||||||
@@ -1565,7 +1565,7 @@ describe("ChatView", () => {
|
|||||||
await userEvent.click(sendButton);
|
await userEvent.click(sendButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(sendRoomMessage).toHaveBeenCalledWith("", { files: [textFile] });
|
expect(sendRoomMessage).toHaveBeenCalledWith("", expect.objectContaining({ files: [textFile] }));
|
||||||
});
|
});
|
||||||
expect(sendMessage).not.toHaveBeenCalled();
|
expect(sendMessage).not.toHaveBeenCalled();
|
||||||
expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument();
|
||||||
|
|||||||
@@ -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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
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 () => {
|
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");
|
const restoreMatchMedia = mockViewportMode("mobile");
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1009,7 +1009,7 @@ describe("ChatView mobile behavior", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(sendRoomMessage).toHaveBeenCalledTimes(1);
|
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(sendMessage).not.toHaveBeenCalled();
|
||||||
expect(document.activeElement).toBe(input);
|
expect(document.activeElement).toBe(input);
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
|||||||
await userEvent.type(textarea, "Hello room{enter}");
|
await userEvent.type(textarea, "Hello room{enter}");
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(sendRoomMessage).toHaveBeenCalledWith("Hello room", { files: [] });
|
expect(sendRoomMessage).toHaveBeenCalledWith("Hello room", expect.objectContaining({ files: [] }));
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
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 userEvent.type(textarea, "Room upload{enter}");
|
||||||
|
|
||||||
await waitFor(() => {
|
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");
|
expect(addToast).not.toHaveBeenCalledWith(expect.stringMatching(/attach/i), "warning");
|
||||||
});
|
});
|
||||||
@@ -504,7 +504,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(sendRoomMessage).toHaveBeenCalledTimes(1);
|
expect(sendRoomMessage).toHaveBeenCalledTimes(1);
|
||||||
expect(sendRoomMessage).toHaveBeenCalledWith("single send", { files: [] });
|
expect(sendRoomMessage).toHaveBeenCalledWith("single send", expect.objectContaining({ files: [] }));
|
||||||
});
|
});
|
||||||
|
|
||||||
resolveSend!();
|
resolveSend!();
|
||||||
@@ -526,7 +526,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
|||||||
await userEvent.type(textarea, "Will retry{enter}");
|
await userEvent.type(textarea, "Will retry{enter}");
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(sendRoomMessage).toHaveBeenCalledWith("Will retry", { files: [] });
|
expect(sendRoomMessage).toHaveBeenCalledWith("Will retry", expect.objectContaining({ files: [] }));
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(textarea.value).toBe("");
|
expect(textarea.value).toBe("");
|
||||||
@@ -545,7 +545,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
|||||||
await userEvent.type(textarea, "Will retry{enter}");
|
await userEvent.type(textarea, "Will retry{enter}");
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(sendRoomMessage).toHaveBeenCalledWith("Will retry", { files: [] });
|
expect(sendRoomMessage).toHaveBeenCalledWith("Will retry", expect.objectContaining({ files: [] }));
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(textarea.value).toBe("Will retry");
|
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;
|
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
||||||
await userEvent.type(textarea, "Retry attachment{enter}");
|
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"));
|
await waitFor(() => expect(textarea.value).toBe("Retry attachment"));
|
||||||
expect(screen.getByText("retry.txt")).toBeInTheDocument();
|
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<void>(() => {});
|
||||||
|
});
|
||||||
|
setup({}, { sendRoomMessage, activeRoom: roomA });
|
||||||
|
|
||||||
|
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||||
|
|
||||||
|
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 () => {
|
it("clears room composer on Enter when room send succeeds", async () => {
|
||||||
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
|
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
|
||||||
setup({}, { sendRoomMessage, activeRoom: roomA });
|
setup({}, { sendRoomMessage, activeRoom: roomA });
|
||||||
@@ -580,7 +598,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
|||||||
await userEvent.type(textarea, "Delivered{enter}");
|
await userEvent.type(textarea, "Delivered{enter}");
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(sendRoomMessage).toHaveBeenCalledWith("Delivered", { files: [] });
|
expect(sendRoomMessage).toHaveBeenCalledWith("Delivered", expect.objectContaining({ files: [] }));
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(textarea.value).toBe("");
|
expect(textarea.value).toBe("");
|
||||||
@@ -601,7 +619,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
|||||||
await userEvent.type(textarea, "Optimistic clear{enter}");
|
await userEvent.type(textarea, "Optimistic clear{enter}");
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(sendRoomMessage).toHaveBeenCalledWith("Optimistic clear", { files: [] });
|
expect(sendRoomMessage).toHaveBeenCalledWith("Optimistic clear", expect.objectContaining({ files: [] }));
|
||||||
});
|
});
|
||||||
expect(textarea.value).toBe("");
|
expect(textarea.value).toBe("");
|
||||||
|
|
||||||
@@ -644,6 +662,24 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
|||||||
expect(sendRoomMessage).not.toHaveBeenCalled();
|
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(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
|
||||||
|
|
||||||
|
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 () => {
|
it("does not intercept /clear substring commands in rooms scope", async () => {
|
||||||
const clearRoom = vi.fn().mockResolvedValue(undefined);
|
const clearRoom = vi.fn().mockResolvedValue(undefined);
|
||||||
const sendRoomMessage = 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 userEvent.type(textarea, "/clear now{enter}");
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(sendRoomMessage).toHaveBeenCalledWith("/clear now", { files: [] });
|
expect(sendRoomMessage).toHaveBeenCalledWith("/clear now", expect.objectContaining({ files: [] }));
|
||||||
});
|
});
|
||||||
expect(clearRoom).not.toHaveBeenCalled();
|
expect(clearRoom).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -773,7 +809,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledTimes(1));
|
||||||
expect(sendRoomMessage).toHaveBeenCalledWith("Room iOS tap", { files: [] });
|
expect(sendRoomMessage).toHaveBeenCalledWith("Room iOS tap", expect.objectContaining({ files: [] }));
|
||||||
mediaSpy.mockRestore();
|
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" }));
|
firstSendButton.dispatchEvent(Object.assign(new Event("pointerdown", { bubbles: true, cancelable: true }), { pointerType: "touch" }));
|
||||||
});
|
});
|
||||||
await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledTimes(1));
|
||||||
expect(sendRoomMessage).toHaveBeenLastCalledWith("Room first", { files: [] });
|
expect(sendRoomMessage).toHaveBeenLastCalledWith("Room first", expect.objectContaining({ files: [] }));
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
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));
|
await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledTimes(2));
|
||||||
expect(sendRoomMessage).toHaveBeenLastCalledWith("Room second", { files: [] });
|
expect(sendRoomMessage).toHaveBeenLastCalledWith("Room second", expect.objectContaining({ files: [] }));
|
||||||
mediaSpy.mockRestore();
|
mediaSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -823,7 +859,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledTimes(1));
|
||||||
expect(sendRoomMessage).toHaveBeenCalledWith("Room Android tap", { files: [] });
|
expect(sendRoomMessage).toHaveBeenCalledWith("Room Android tap", expect.objectContaining({ files: [] }));
|
||||||
mediaSpy.mockRestore();
|
mediaSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -843,7 +879,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledTimes(1));
|
||||||
expect(sendRoomMessage).toHaveBeenCalledWith("Room desktop tap", { files: [] });
|
expect(sendRoomMessage).toHaveBeenCalledWith("Room desktop tap", expect.objectContaining({ files: [] }));
|
||||||
mediaSpy.mockRestore();
|
mediaSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
it("keeps accepted sent message visible after provider error and reconciles persisted echo", async () => {
|
||||||
mockFetchChatSessions.mockResolvedValueOnce({
|
mockFetchChatSessions.mockResolvedValueOnce({
|
||||||
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
|
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
|
||||||
|
|||||||
@@ -445,6 +445,31 @@ describe("useChatRooms", () => {
|
|||||||
}, "proj-1");
|
}, "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<void>;
|
||||||
|
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 () => {
|
it("throws on upload failure and does not post", async () => {
|
||||||
const active = room("room-1", "one", "2026-05-09T01:00:00.000Z");
|
const active = room("room-1", "one", "2026-05-09T01:00:00.000Z");
|
||||||
mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] });
|
mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] });
|
||||||
@@ -457,13 +482,14 @@ describe("useChatRooms", () => {
|
|||||||
await waitFor(() => expect(result.current.activeRoom?.id).toBe("room-1"));
|
await waitFor(() => expect(result.current.activeRoom?.id).toBe("room-1"));
|
||||||
|
|
||||||
const file = new File(["x"], "bad.txt", { type: "text/plain" });
|
const file = new File(["x"], "bad.txt", { type: "text/plain" });
|
||||||
|
const onDelivered = vi.fn();
|
||||||
mockUploadChatRoomAttachment.mockRejectedValueOnce(new Error("Upload failed"));
|
mockUploadChatRoomAttachment.mockRejectedValueOnce(new Error("Upload failed"));
|
||||||
mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: [] });
|
mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: [] });
|
||||||
|
|
||||||
let uploadError: unknown;
|
let uploadError: unknown;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
try {
|
try {
|
||||||
await result.current.sendRoomMessage("hello", { files: [file] });
|
await result.current.sendRoomMessage("hello", { files: [file], onDelivered });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
uploadError = error;
|
uploadError = error;
|
||||||
}
|
}
|
||||||
@@ -472,6 +498,7 @@ describe("useChatRooms", () => {
|
|||||||
expect(uploadError).toBeInstanceOf(Error);
|
expect(uploadError).toBeInstanceOf(Error);
|
||||||
expect((uploadError as Error).message).toBe("Failed to upload attachment: bad.txt");
|
expect((uploadError as Error).message).toBe("Failed to upload attachment: bad.txt");
|
||||||
expect(uploadError).not.toBeInstanceOf(RoomMessageDeliveredButReplyFailedError);
|
expect(uploadError).not.toBeInstanceOf(RoomMessageDeliveredButReplyFailedError);
|
||||||
|
expect(onDelivered).not.toHaveBeenCalled();
|
||||||
|
|
||||||
expect(mockPostChatRoomMessage).not.toHaveBeenCalledWith("room-1", expect.objectContaining({ content: "hello" }), "proj-1");
|
expect(mockPostChatRoomMessage).not.toHaveBeenCalledWith("room-1", expect.objectContaining({ content: "hello" }), "proj-1");
|
||||||
});
|
});
|
||||||
@@ -492,6 +519,7 @@ describe("useChatRooms", () => {
|
|||||||
role: "assistant" as const,
|
role: "assistant" as const,
|
||||||
senderAgentId: "agent-1",
|
senderAgentId: "agent-1",
|
||||||
};
|
};
|
||||||
|
const onDelivered = vi.fn();
|
||||||
mockPostChatRoomMessage.mockRejectedValueOnce(new Error("POST failed"));
|
mockPostChatRoomMessage.mockRejectedValueOnce(new Error("POST failed"));
|
||||||
mockFetchChatRoomMessages.mockResolvedValueOnce({
|
mockFetchChatRoomMessages.mockResolvedValueOnce({
|
||||||
messages: [recoveredAssistantReply],
|
messages: [recoveredAssistantReply],
|
||||||
@@ -500,7 +528,7 @@ describe("useChatRooms", () => {
|
|||||||
let postError: unknown;
|
let postError: unknown;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
try {
|
try {
|
||||||
await result.current.sendRoomMessage("hello");
|
await result.current.sendRoomMessage("hello", { onDelivered });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
postError = error;
|
postError = error;
|
||||||
}
|
}
|
||||||
@@ -509,6 +537,7 @@ describe("useChatRooms", () => {
|
|||||||
expect(postError).toBeInstanceOf(Error);
|
expect(postError).toBeInstanceOf(Error);
|
||||||
expect((postError as Error).message).toBe("POST failed");
|
expect((postError as Error).message).toBe("POST failed");
|
||||||
expect(postError).not.toBeInstanceOf(RoomMessageDeliveredButReplyFailedError);
|
expect(postError).not.toBeInstanceOf(RoomMessageDeliveredButReplyFailedError);
|
||||||
|
expect(onDelivered).not.toHaveBeenCalled();
|
||||||
|
|
||||||
expect(result.current.messages).toEqual([recoveredAssistantReply]);
|
expect(result.current.messages).toEqual([recoveredAssistantReply]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export interface UseChatReturn {
|
|||||||
sendMessage: (
|
sendMessage: (
|
||||||
content: string,
|
content: string,
|
||||||
attachments?: File[],
|
attachments?: File[],
|
||||||
callbacks?: { onDelivered?: () => void; onFailed?: () => void },
|
callbacks?: { onAccepted?: () => void; onDelivered?: () => void; onFailed?: () => void },
|
||||||
) => void;
|
) => void;
|
||||||
/**
|
/**
|
||||||
* FNXC:ChatMessageEdit 2026-07-07-09:00:
|
* FNXC:ChatMessageEdit 2026-07-07-09:00:
|
||||||
@@ -1392,7 +1392,7 @@ export function useChat(
|
|||||||
const sendMessageRef = useRef<(
|
const sendMessageRef = useRef<(
|
||||||
content: string,
|
content: string,
|
||||||
attachments?: File[],
|
attachments?: File[],
|
||||||
callbacks?: { onDelivered?: () => void; onFailed?: () => void },
|
callbacks?: { onAccepted?: () => void; onDelivered?: () => void; onFailed?: () => void },
|
||||||
) => void>(() => {
|
) => void>(() => {
|
||||||
// no-op until sendMessage is defined
|
// no-op until sendMessage is defined
|
||||||
});
|
});
|
||||||
@@ -1439,7 +1439,7 @@ export function useChat(
|
|||||||
(
|
(
|
||||||
content: string,
|
content: string,
|
||||||
attachments?: File[],
|
attachments?: File[],
|
||||||
callbacks?: { onDelivered?: () => void; onFailed?: () => void },
|
callbacks?: { onAccepted?: () => void; onDelivered?: () => void; onFailed?: () => void },
|
||||||
) => {
|
) => {
|
||||||
if (!activeSession) {
|
if (!activeSession) {
|
||||||
callbacks?.onFailed?.();
|
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],
|
[activeSession, projectId, refreshSessions, addToast, attachIfGenerating, reconnectSessionSilently, flushPendingMessage],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export interface UseChatRoomsResult {
|
|||||||
createRoom: (input: { name: string; memberAgentIds: string[] }) => Promise<ChatRoom>;
|
createRoom: (input: { name: string; memberAgentIds: string[] }) => Promise<ChatRoom>;
|
||||||
updateRoomSettings: (roomId: string, updates: { thinkingLevel?: string | null }) => Promise<ChatRoom>;
|
updateRoomSettings: (roomId: string, updates: { thinkingLevel?: string | null }) => Promise<ChatRoom>;
|
||||||
deleteRoom: (roomId: string) => Promise<void>;
|
deleteRoom: (roomId: string) => Promise<void>;
|
||||||
sendRoomMessage: (content: string, opts?: { attachments?: ChatAttachment[]; files?: File[] }) => Promise<void>;
|
sendRoomMessage: (content: string, opts?: { attachments?: ChatAttachment[]; files?: File[]; onDelivered?: () => void }) => Promise<void>;
|
||||||
clearRoom: (roomId: string) => Promise<void>;
|
clearRoom: (roomId: string) => Promise<void>;
|
||||||
refreshRooms: () => Promise<void>;
|
refreshRooms: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -340,7 +340,7 @@ export function useChatRooms(
|
|||||||
* FNXC:RoomChatReliability 2026-07-01-00:00:
|
* 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.
|
* 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 activeRoomSnapshot = activeRoomRef.current;
|
||||||
const roomId = activeRoomSnapshot?.id;
|
const roomId = activeRoomSnapshot?.id;
|
||||||
if (!roomId) {
|
if (!roomId) {
|
||||||
@@ -389,6 +389,15 @@ export function useChatRooms(
|
|||||||
...(mergedAttachments.length ? { attachments: mergedAttachments } : {}),
|
...(mergedAttachments.length ? { attachments: mergedAttachments } : {}),
|
||||||
}, projectId);
|
}, projectId);
|
||||||
userMessageDelivered = true;
|
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) {
|
if (postResult.message?.createdAt && activeRoomSnapshot) {
|
||||||
setRooms((previous) => upsertRoom(previous, { ...activeRoomSnapshot, updatedAt: postResult.message.createdAt }));
|
setRooms((previous) => upsertRoom(previous, { ...activeRoomSnapshot, updatedAt: postResult.message.createdAt }));
|
||||||
|
|||||||
Reference in New Issue
Block a user