From a74722589f20c42042ada8f9df92a1b785b22caf Mon Sep 17 00:00:00 2001 From: abhi1992002 Date: Fri, 16 Oct 2026 05:08:38 +0000 Subject: [PATCH 1/9] test(frontend): the expert side-panel chat drops attached files The drawer's onSend ignores the files and workspace files ChatInput hands it, and draws nothing until the session exists. These tests send a local file in a new thread, a workspace file in an existing one, a file while the expert is still replying, and a file queued behind the kickoff, and expect the message on screen at once and file_ids on /stream. They fail until the fix lands. Co-authored-by: Claude Opus 5.5 (Claude Code) --- .../__tests__/attachments.test.tsx | 402 ++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/__tests__/attachments.test.tsx diff --git a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/__tests__/attachments.test.tsx b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/__tests__/attachments.test.tsx new file mode 100644 index 000000000000..a3f58a72ad1a --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/__tests__/attachments.test.tsx @@ -0,0 +1,402 @@ +import { TEST_BACKEND_BASE_URL } from "@/app/(platform)/copilot/__tests__/sse-helpers"; +import { useCopilotStreamStore } from "@/app/(platform)/copilot/copilotStreamStore"; +import { + markKickoffDone, + markKickoffPending, +} from "@/app/(platform)/copilot/expertKickoff"; +import { + getGetV2GetSessionMockHandler200, + getGetV2GetSessionResponseMock200, + getGetV2ListSessionsMockHandler200, + getPostV2CreateSessionResponseMock200, +} from "@/app/api/__generated__/endpoints/chat/chat.msw"; +import { + getListWorkspaceFilesMockHandler200, + getListWorkspaceFoldersMockHandler200, +} from "@/app/api/__generated__/endpoints/workspace/workspace.msw"; +import { useAuthStore } from "@/lib/auth/hooks/useAuthStore"; +import { server } from "@/mocks/mock-server"; +import { + assistantTextChunks, + createStreamHold, + streamSseResponse, +} from "@/tests/integrations/copilot-sse"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@/tests/integrations/test-utils"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse, ws } from "msw"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { ExpertChatDrawer } from "../ExpertChatDrawer"; + +const USER_ID = "user-1"; +const EXPERT_ID = "3f8b0f7e-9f30-4a3b-a6a1-000000000001"; +const SESSION_ID = "session-zara"; +const FRESH_SESSION_ID = "session-zara-fresh"; +const UPLOADED_FILE_ID = "7a1c0f7e-9f30-4a3b-a6a1-0000000000aa"; +const WORKSPACE_FILE_ID = "7a1c0f7e-9f30-4a3b-a6a1-0000000000bb"; + +vi.mock("@/lib/auth/actions", async (importOriginal) => ({ + ...(await importOriginal()), + getWebSocketToken: async () => ({ token: "test-token" }), +})); + +vi.mock("@/services/environment", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + environment: { + ...actual.environment, + getAGPTServerBaseUrl: () => TEST_BACKEND_BASE_URL, + }, + }; +}); + +vi.mock("@/app/(platform)/copilot/helpers", async (importActual) => { + const actual = + await importActual(); + return { + ...actual, + getCopilotAuthHeaders: async () => ({ "x-test-auth": "yes" }), + }; +}); + +vi.mock("@/services/feature-flags/use-get-flag", async (importActual) => { + const actual = + await importActual< + typeof import("@/services/feature-flags/use-get-flag") + >(); + return { + ...actual, + useGetFlag: (flag: Parameters[0]) => { + const value = actual.useGetFlag(flag); + return flag === actual.Flag.CHAT_WORKSPACE_FILES ? true : value; + }, + }; +}); + +const backendSocket = ws.link("ws://localhost:8001/ws"); + +const ZARA = { + expertId: EXPERT_ID, + name: "Zara", + role: "GTM Strategist", + avatarUrl: null, +}; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +interface Recorder { + streamBodies: Array>; + uploadSessionIds: Array; + pendingPosts: number; +} + +function newRecorder(): Recorder { + return { streamBodies: [], uploadSessionIds: [], pendingPosts: 0 }; +} + +function uploadHandler(recorder: Recorder) { + return http.post( + `${TEST_BACKEND_BASE_URL}/api/workspace/files/upload`, + ({ request }) => { + recorder.uploadSessionIds.push( + new URL(request.url).searchParams.get("session_id"), + ); + return HttpResponse.json({ + file_id: UPLOADED_FILE_ID, + name: "photo.png", + path: "/photo.png", + mime_type: "image/png", + size_bytes: 2048, + }); + }, + ); +} + +function pendingHandler(recorder: Recorder, sessionId: string) { + return http.post( + `${TEST_BACKEND_BASE_URL}/api/chat/sessions/${sessionId}/messages/pending`, + () => { + recorder.pendingPosts += 1; + return HttpResponse.json({ buffer_length: 1 }); + }, + ); +} + +function streamHandler( + recorder: Recorder, + sessionId: string, + holds: Array["hold"] | undefined> = [], +) { + return http.post( + `${TEST_BACKEND_BASE_URL}/api/chat/sessions/${sessionId}/stream`, + async ({ request }) => { + const turn = recorder.streamBodies.length; + recorder.streamBodies.push(await request.clone().json()); + return streamSseResponse(assistantTextChunks(`Reply ${turn + 1}`), { + abortSignal: request.signal, + hold: holds[turn], + }); + }, + ); +} + +function sessionHandler(sessionId: string) { + return getGetV2GetSessionMockHandler200( + getGetV2GetSessionResponseMock200({ + id: sessionId, + expert_id: EXPERT_ID, + messages: [], + active_stream: null, + }), + ); +} + +function existingThreadList() { + return getGetV2ListSessionsMockHandler200({ + sessions: [ + { + id: SESSION_ID, + created_at: "2026-09-27T18:00:00Z", + updated_at: "2026-09-27T18:01:00Z", + is_processing: false, + expert_id: EXPERT_ID, + }, + ], + total: 1, + }); +} + +function photo() { + return new File(["x".repeat(2048)], "photo.png", { type: "image/png" }); +} + +async function attachLocalFile(user: ReturnType) { + const input = document.querySelector('input[type="file"]'); + if (!input) throw new Error("The composer has no file input"); + await user.upload(input, photo()); + await screen.findByText("photo.png"); +} + +async function enabledComposer() { + const input = (await screen.findByPlaceholderText( + "Message Zara…", + )) as HTMLTextAreaElement; + await waitFor(() => expect(input.disabled).toBe(false)); + return input; +} + +function pendingBubble() { + return screen.getByTestId("pending-upload-message"); +} + +beforeEach(() => { + window.localStorage.clear(); + useCopilotStreamStore.getState().resetAll(); + server.use(backendSocket.addEventListener("connection", () => {})); + useAuthStore.setState({ + user: { id: USER_ID, email: "zara-owner@example.com", user_metadata: {} }, + isUserLoading: false, + hasLoadedUser: true, + }); +}); + +afterEach(() => { + useAuthStore.setState({ user: null, hasLoadedUser: false }); +}); + +describe("ExpertChatDrawer attachments", () => { + test("shows the message at once and sends a local file in a new thread", async () => { + markKickoffDone(USER_ID, EXPERT_ID, markKickoffPending(USER_ID, EXPERT_ID)); + const user = userEvent.setup(); + const recorder = newRecorder(); + const createRequest = deferred(); + server.use( + getGetV2ListSessionsMockHandler200({ sessions: [], total: 0 }), + http.post("/api/proxy/api/chat/sessions", async () => { + await createRequest.promise; + return HttpResponse.json( + getPostV2CreateSessionResponseMock200({ id: FRESH_SESSION_ID }), + ); + }), + sessionHandler(FRESH_SESSION_ID), + uploadHandler(recorder), + streamHandler(recorder, FRESH_SESSION_ID), + ); + + render( + {}} + resumeLatest={false} + />, + ); + + const input = await enabledComposer(); + await attachLocalFile(user); + await user.type(input, "what is this{Enter}"); + + const bubble = await screen.findByTestId("pending-upload-message"); + expect(within(bubble).getByText("what is this")).toBeDefined(); + expect(within(bubble).getByText("photo.png")).toBeDefined(); + expect(screen.queryByText("What can I do for you?")).toBeNull(); + expect(recorder.uploadSessionIds).toEqual([]); + + createRequest.resolve(); + + await waitFor(() => expect(recorder.streamBodies.length).toBe(1)); + expect(recorder.uploadSessionIds).toEqual([FRESH_SESSION_ID]); + expect(recorder.streamBodies[0]).toMatchObject({ + message: "what is this", + file_ids: [UPLOADED_FILE_ID], + }); + expect(await screen.findByText("Reply 1")).toBeDefined(); + }); + + test("sends a workspace file picked in an existing thread", async () => { + const user = userEvent.setup(); + const recorder = newRecorder(); + server.use( + existingThreadList(), + sessionHandler(SESSION_ID), + getListWorkspaceFoldersMockHandler200({ folders: [] }), + getListWorkspaceFilesMockHandler200({ + files: [ + { + id: WORKSPACE_FILE_ID, + name: "brief.pdf", + path: "/brief.pdf", + mime_type: "application/pdf", + size_bytes: 4096, + created_at: "2026-09-27T18:00:00Z", + }, + ], + has_more: false, + }), + streamHandler(recorder, SESSION_ID), + ); + + render( {}} />); + + const input = await enabledComposer(); + fireEvent.pointerDown(screen.getByTestId("composer-plus-button"), { + button: 0, + }); + fireEvent.click( + await screen.findByRole("menuitem", { name: /Use File from Workspace/ }), + ); + fireEvent.click(await screen.findByRole("button", { name: /brief\.pdf/ })); + fireEvent.click(screen.getByRole("button", { name: "Add 1 file" })); + await user.type(input, "summarize this{Enter}"); + + await waitFor(() => expect(recorder.streamBodies.length).toBe(1)); + expect(recorder.streamBodies[0]).toMatchObject({ + message: "summarize this", + file_ids: [WORKSPACE_FILE_ID], + }); + }); + + test("holds a file sent mid-reply and sends it as the next turn", async () => { + const user = userEvent.setup(); + const recorder = newRecorder(); + const firstReply = createStreamHold(3); + server.use( + existingThreadList(), + sessionHandler(SESSION_ID), + uploadHandler(recorder), + pendingHandler(recorder, SESSION_ID), + streamHandler(recorder, SESSION_ID, [firstReply.hold]), + ); + + render( {}} />); + + const input = await enabledComposer(); + await user.type(input, "hello{Enter}"); + await waitFor(() => expect(recorder.streamBodies.length).toBe(1)); + + await attachLocalFile(user); + await user.type(input, "and this{Enter}"); + + const bubble = await screen.findByTestId("pending-upload-message"); + expect(within(bubble).getByText("and this")).toBeDefined(); + expect(within(bubble).getByText("photo.png")).toBeDefined(); + await waitFor(() => + expect(recorder.uploadSessionIds).toEqual([SESSION_ID]), + ); + expect(recorder.streamBodies.length).toBe(1); + + firstReply.release(); + + await waitFor(() => expect(recorder.streamBodies.length).toBe(2)); + expect(recorder.pendingPosts).toBe(0); + expect(recorder.streamBodies[1]).toMatchObject({ + message: "and this", + file_ids: [UPLOADED_FILE_ID], + }); + }); + + test("sends a file queued behind the kickoff once the kickoff replies", async () => { + const user = userEvent.setup(); + const recorder = newRecorder(); + const kickoffReply = createStreamHold(3); + let createAttempts = 0; + server.use( + getGetV2ListSessionsMockHandler200({ sessions: [], total: 0 }), + http.post("/api/proxy/api/chat/sessions", () => { + createAttempts += 1; + if (createAttempts === 1) { + return HttpResponse.json( + { detail: "Could not create session" }, + { status: 500 }, + ); + } + return HttpResponse.json( + getPostV2CreateSessionResponseMock200({ id: FRESH_SESSION_ID }), + ); + }), + sessionHandler(FRESH_SESSION_ID), + uploadHandler(recorder), + streamHandler(recorder, FRESH_SESSION_ID, [kickoffReply.hold]), + ); + + render( + {}} + resumeLatest={false} + />, + ); + + await waitFor(() => expect(createAttempts).toBe(1)); + const input = await enabledComposer(); + await attachLocalFile(user); + await user.type(input, "what is this{Enter}"); + + expect(within(pendingBubble()).getByText("what is this")).toBeDefined(); + + await waitFor(() => expect(recorder.streamBodies.length).toBe(1)); + expect(recorder.streamBodies[0].expert_kickoff).toBe(true); + expect(within(pendingBubble()).getByText("photo.png")).toBeDefined(); + + kickoffReply.release(); + + await waitFor(() => expect(recorder.streamBodies.length).toBe(2)); + expect(recorder.uploadSessionIds).toEqual([FRESH_SESSION_ID]); + expect(recorder.streamBodies[1]).toMatchObject({ + expert_kickoff: false, + message: "what is this", + file_ids: [UPLOADED_FILE_ID], + }); + }); +}); From 0d536871b0471b73b70f12c76c44cdc5ed98cbfa Mon Sep 17 00:00:00 2001 From: abhi1992002 Date: Fri, 16 Oct 2026 05:36:36 +0000 Subject: [PATCH 2/9] refactor(frontend/copilot): move the upload-then-send path out of useSendMessage The upload, placeholder and send steps lived inside the /copilot hook, so the expert side panel could not reuse them and sent text only. They now live in helpers/sendWithAttachments.ts as one OutgoingMessage type and a sendWithAttachments() both surfaces can call, with an optional waitUntilReady step between the uploads and the send. The settle waiters in useCopilotPage move to useSettleGate so the panel can wait on its own reply the same way. No behaviour change on /copilot. Co-authored-by: Claude Opus 5.5 (Claude Code) --- .../copilot/helpers/sendWithAttachments.ts | 218 ++++++++++++++++ .../app/(platform)/copilot/useCopilotPage.ts | 31 +-- .../app/(platform)/copilot/useSendMessage.ts | 236 +++--------------- .../app/(platform)/copilot/useSettleGate.ts | 38 +++ 4 files changed, 293 insertions(+), 230 deletions(-) create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/helpers/sendWithAttachments.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/useSettleGate.ts diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/sendWithAttachments.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/sendWithAttachments.ts new file mode 100644 index 000000000000..f6bef8aa8ba0 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/sendWithAttachments.ts @@ -0,0 +1,218 @@ +import { toast } from "@/components/molecules/Toast/use-toast"; +import { uploadFileDirect } from "@/lib/direct-upload"; +import type { FileUIPart, UIMessage } from "ai"; +import { + useCopilotStreamStore, + type PendingUploadAttachment, + type PendingUploadSend, +} from "../copilotStreamStore"; +import type { ExpertKickoffMetadata } from "../expertKickoff"; +import type { SendInput } from "../stream/turnRuntime"; +import { + isFolderPart, + MAX_ATTACHMENTS, + workspaceFileDownloadUrl, + type StoredAttachmentPart, +} from "./workspaceAttachments"; + +const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024; + +/** One message on its way out: its text, the local files still to upload, + * and the workspace references that are already stored. */ +export interface OutgoingMessage { + text: string; + files: File[]; + parts: StoredAttachmentPart[]; + metadata?: ExpertKickoffMetadata; +} + +interface UploadedFile { + file_id: string; + name: string; + mime_type: string; +} + +type SendMessageFn = (message: SendInput) => Promise; + +interface SendArgs { + sessionId: string; + message: OutgoingMessage; + sendMessage: SendMessageFn; + /** Awaited once the uploads are done and before the send: `true` sends, + * `false` drops the message. The placeholder shows the whole time. */ + waitUntilReady?: () => Promise; +} + +export function hasAttachments(message: OutgoingMessage): boolean { + return message.files.length > 0 || message.parts.length > 0; +} + +/** Backstop for the composer's own caps; toasts and returns false when the + * message cannot go out. */ +export function validateAttachments(message: OutgoingMessage): boolean { + // Workspace references count too — uploaded or not, every attachment + // becomes one `file_ids` entry on the request the backend bounds. + if (message.files.length + message.parts.length > MAX_ATTACHMENTS) { + toast({ + title: "Too many attachments", + description: `You can attach up to ${MAX_ATTACHMENTS} attachments per message.`, + variant: "destructive", + }); + return false; + } + const oversized = message.files.filter((f) => f.size > MAX_FILE_SIZE_BYTES); + if (oversized.length > 0) { + toast({ + title: "File too large", + description: `${oversized[0].name} exceeds the 100 MB limit.`, + variant: "destructive", + }); + return false; + } + return true; +} + +/** + * Uploads the message's local files into the session, then sends the message + * with every attachment as a file part. While that runs, the session's + * placeholder bubble shows the message and reads "Uploading N files…". + */ +export async function sendWithAttachments({ + sessionId, + message, + sendMessage, + waitUntilReady, +}: SendArgs) { + const { text, files, parts, metadata } = message; + // The per-click UUID that becomes the backend's ``ChatMessage.id`` + // is generated inside the transport's ``prepareSendMessagesRequest`` + // (one call per ``sendMessages``, stable across SDK-internal + // retries) — NOT here. AI SDK's ``messageId`` arg means + // "replace-existing-message" (edit mode), not "id of a new + // message", so passing it would put the SDK into edit-mode with no + // target and break the optimistic-render path that pushes the user + // bubble into ``messages`` synchronously. + if (files.length === 0 && !waitUntilReady) { + await sendMessage(messageInput(text, parts, metadata)); + return; + } + // The bubble shows right away; the transcript reads "Uploading N files…" + // until the uploads land and the real message takes the placeholder's + // spot. The slot is set here because this is the one path every send with + // files goes through, including the flush after session creation. + const pending = describePendingUpload(message); + useCopilotStreamStore.getState().setPendingUploadSend(sessionId, pending); + let send: Promise | undefined; + try { + const uploaded = await uploadFiles(files, sessionId); + let sentParts = [...parts, ...buildFileParts(uploaded)]; + if (files.length > 0 && uploaded.length === 0) { + toast({ + title: "File upload failed", + description: "Could not upload any files. Please try again.", + variant: "destructive", + }); + // The workspace references didn't fail to upload (they need no upload), + // so don't discard them just because the local uploads failed. + if (parts.length === 0) { + throw new Error("All file uploads failed"); + } + sentParts = parts; + } + if (waitUntilReady && !(await waitUntilReady())) return; + send = sendMessage(messageInput(text, sentParts, metadata)); + } finally { + // `sendMessage` pushes the user bubble into `messages` synchronously, + // so the placeholder can go in the same tick with no gap between the + // two. Its promise only settles when the whole stream ends, which is + // why the cleanup happens here and not after the await below. + useCopilotStreamStore.getState().clearPendingUploadSend(sessionId, pending); + } + await send; +} + +export function describePendingUpload( + message: OutgoingMessage, +): PendingUploadSend { + const stored: PendingUploadAttachment[] = message.parts.map((part) => + isFolderPart(part) + ? { name: part.data.name, mediaType: "folder", isUploading: false } + : { + name: part.filename ?? "file", + mediaType: part.mediaType, + isUploading: false, + }, + ); + const local: PendingUploadAttachment[] = message.files.map((file) => ({ + name: file.name, + mediaType: file.type || "application/octet-stream", + sizeBytes: file.size, + isUploading: true, + })); + return { text: message.text, attachments: [...stored, ...local] }; +} + +async function uploadFiles( + files: File[], + sessionId: string, +): Promise { + const results = await Promise.allSettled( + files.map(async (file) => { + try { + const data = await uploadFileDirect(file, sessionId); + if (!data.file_id) throw new Error("No file_id returned"); + return { + file_id: data.file_id, + name: data.name || file.name, + mime_type: data.mime_type || "application/octet-stream", + } as UploadedFile; + } catch (err) { + console.error("File upload failed:", err); + toast({ + title: "File upload failed", + description: file.name, + variant: "destructive", + }); + throw err; + } + }), + ); + return results + .filter( + (r): r is PromiseFulfilledResult => + r.status === "fulfilled", + ) + .map((r) => r.value); +} + +function buildFileParts(uploaded: UploadedFile[]): FileUIPart[] { + return uploaded.map((f) => ({ + type: "file" as const, + mediaType: f.mime_type, + filename: f.name, + url: workspaceFileDownloadUrl(f.file_id), + })); +} + +function messageInput( + text: string, + parts: StoredAttachmentPart[], + metadata?: ExpertKickoffMetadata, +) { + // `{ text, files }` only accepts FileUIParts, so a message carrying a + // folder is built from its parts instead. + if (!parts.some(isFolderPart)) { + return { + text, + files: parts.length > 0 ? (parts as FileUIPart[]) : undefined, + metadata, + }; + } + return { + parts: [ + ...parts, + ...(text ? [{ type: "text" as const, text }] : []), + ] as UIMessage["parts"], + metadata, + }; +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts index d82d20b7b81b..61d4f642750f 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts @@ -32,6 +32,7 @@ import { useCopilotStream } from "./useCopilotStream"; import { resolveExpertIdentity, useExpertMap } from "./useExpertMap"; import { useLoadMoreMessages } from "./useLoadMoreMessages"; import { recoverFailedDeferredSend, useSendMessage } from "./useSendMessage"; +import { useSettleGate } from "./useSettleGate"; import { useSessionTitlePoll } from "./useSessionTitlePoll"; import { useWorkflowImportAutoSubmit } from "./useWorkflowImportAutoSubmit"; import { useCompleteBrainDumpGreeting } from "@/app/api/__generated__/endpoints/brain-dump/brain-dump"; @@ -261,29 +262,11 @@ export function useCopilotPage({ // turn — see onSend. const isLocalStreamSettled = !isInflightRef.current && !isFinishProbing && !isReconnecting; - const isLocalStreamSettledRef = useRef(isLocalStreamSettled); - isLocalStreamSettledRef.current = isLocalStreamSettled; - // Each waiter learns whether the stream settled (send now) or the hook - // went away first (the chat host unmounts on a chat switch and on reload; - // the follow-up then stays in sessionStorage, see below). - const settleWaitersRef = useRef void>>([]); - useEffect(() => { - if (!isLocalStreamSettled) return; - settleWaitersRef.current.splice(0).forEach((resolve) => resolve(true)); - }, [isLocalStreamSettled]); - useEffect( - () => () => { - settleWaitersRef.current.splice(0).forEach((resolve) => resolve(false)); - }, - [], - ); - - function waitForLocalSettle() { - if (isLocalStreamSettledRef.current) return Promise.resolve(true); - return new Promise((resolve) => { - settleWaitersRef.current.push(resolve); - }); - } + // A held follow-up learns whether the stream settled (send now) or the + // hook went away first (the chat host unmounts on a chat switch and on + // reload; the follow-up then stays in sessionStorage, see below). + const { waitUntilSettled: waitForLocalSettle, markUnsettled } = + useSettleGate(isLocalStreamSettled); // Follow-ups waiting on the settle above. Shown as "Queued" chips beside // the backend-buffered ones, since from the user's side they are the same @@ -455,7 +438,7 @@ export function useCopilotPage({ // instead of triggering a duplicate /stream POST. if (sessionId) { isInflightRef.current = true; - isLocalStreamSettledRef.current = false; + markUnsettled(); } if (heldForLocalSettle && sessionId) { releaseFollowUp(sessionId, trimmed); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useSendMessage.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useSendMessage.ts index 18533ccf6b5f..69a80eabc09a 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useSendMessage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useSendMessage.ts @@ -1,34 +1,22 @@ -import { toast } from "@/components/molecules/Toast/use-toast"; -import { uploadFileDirect } from "@/lib/direct-upload"; -import type { FileUIPart, UIMessage } from "ai"; import { useEffect, useRef } from "react"; -import { - pendingUploadKey, - useCopilotStreamStore, - type PendingUploadAttachment, - type PendingUploadSend, -} from "./copilotStreamStore"; +import { pendingUploadKey, useCopilotStreamStore } from "./copilotStreamStore"; import { toastSendFailure } from "./helpers/sendFailureToast"; import type { ExpertKickoffMetadata } from "./expertKickoff"; +import { + describePendingUpload, + hasAttachments, + sendWithAttachments, + validateAttachments, + type OutgoingMessage, +} from "./helpers/sendWithAttachments"; import { buildStoredAttachmentParts, - isFolderPart, - MAX_ATTACHMENTS, - workspaceFileDownloadUrl, type StoredAttachmentPart, type WorkspaceAttachment, } from "./helpers/workspaceAttachments"; import { useCopilotUIStore } from "./store"; import type { SendInput } from "./stream/turnRuntime"; -const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024; - -interface UploadedFile { - file_id: string; - name: string; - mime_type: string; -} - type SendMessageFn = (message: SendInput) => Promise; interface Args { @@ -65,126 +53,8 @@ export function useSendMessage({ // the same tick short-circuits. const isCreatingSessionRef = useRef(false); - async function uploadFiles( - files: File[], - sid: string, - ): Promise { - const results = await Promise.allSettled( - files.map(async (file) => { - try { - const data = await uploadFileDirect(file, sid); - if (!data.file_id) throw new Error("No file_id returned"); - return { - file_id: data.file_id, - name: data.name || file.name, - mime_type: data.mime_type || "application/octet-stream", - } as UploadedFile; - } catch (err) { - console.error("File upload failed:", err); - toast({ - title: "File upload failed", - description: file.name, - variant: "destructive", - }); - throw err; - } - }), - ); - return results - .filter( - (r): r is PromiseFulfilledResult => - r.status === "fulfilled", - ) - .map((r) => r.value); - } - - function buildFileParts(uploaded: UploadedFile[]): FileUIPart[] { - return uploaded.map((f) => ({ - type: "file" as const, - mediaType: f.mime_type, - filename: f.name, - url: workspaceFileDownloadUrl(f.file_id), - })); - } - - async function dispatchToSession( - sid: string, - text: string, - files: File[], - prebuiltParts: StoredAttachmentPart[], - metadata?: ExpertKickoffMetadata, - ) { - // The per-click UUID that becomes the backend's ``ChatMessage.id`` - // is generated inside the transport's ``prepareSendMessagesRequest`` - // (one call per ``sendMessages``, stable across SDK-internal - // retries) — NOT here. AI SDK's ``messageId`` arg means - // "replace-existing-message" (edit mode), not "id of a new - // message", so passing it would put the SDK into edit-mode with no - // target and break the optimistic-render path that pushes the user - // bubble into ``messages`` synchronously. - if (files.length === 0) { - await sendMessage(messageInput(text, prebuiltParts, metadata)); - return; - } - // The bubble shows right away; the transcript reads "Uploading N files…" - // until the uploads land and the real message takes the placeholder's - // spot. The slot is re-set here (not only in onSend) because this is the - // one path every send with files goes through, including the flush after - // session creation. - const pending = describePendingUpload(text, files, prebuiltParts); - useCopilotStreamStore.getState().setPendingUploadSend(sid, pending); - let send: Promise | undefined; - try { - const uploaded = await uploadFiles(files, sid); - if (uploaded.length === 0) { - toast({ - title: "File upload failed", - description: "Could not upload any files. Please try again.", - variant: "destructive", - }); - // The workspace references didn't fail to upload (they need no upload), - // so don't discard them just because the local uploads failed. - if (prebuiltParts.length === 0) { - throw new Error("All file uploads failed"); - } - send = sendMessage(messageInput(text, prebuiltParts, metadata)); - } else { - // Merge already-stored workspace parts with the freshly uploaded ones so - // a single message can mix both kinds of attachment. - const allParts = [...prebuiltParts, ...buildFileParts(uploaded)]; - send = sendMessage(messageInput(text, allParts, metadata)); - } - } finally { - // `sendMessage` pushes the user bubble into `messages` synchronously, - // so the placeholder can go in the same tick with no gap between the - // two. Its promise only settles when the whole stream ends, which is - // why the cleanup happens here and not after the await below. - useCopilotStreamStore.getState().clearPendingUploadSend(sid, pending); - } - await send; - } - - function messageInput( - text: string, - parts: StoredAttachmentPart[], - metadata?: ExpertKickoffMetadata, - ) { - // `{ text, files }` only accepts FileUIParts, so a message carrying a - // folder is built from its parts instead. - if (!parts.some(isFolderPart)) { - return { - text, - files: parts.length > 0 ? (parts as FileUIPart[]) : undefined, - metadata, - }; - } - return { - parts: [ - ...parts, - ...(text ? [{ type: "text" as const, text }] : []), - ] as UIMessage["parts"], - metadata, - }; + function dispatchToSession(sid: string, message: OutgoingMessage) { + return sendWithAttachments({ sessionId: sid, message, sendMessage }); } // Hold dispatchToSession in a ref so the queued-send effect can fire @@ -205,7 +75,12 @@ export function useSendMessage({ // dispatch failing. Recover here instead, or an all-uploads-failed first // send rejects with nobody listening and the user loses their message. void dispatchRef - .current(sessionId, send.text, send.files, parts, send.metadata) + .current(sessionId, { + text: send.text, + files: send.files, + parts, + metadata: send.metadata, + }) .catch((error: unknown) => { recoverFailedDeferredSend(send.text, parts, error); }); @@ -218,52 +93,27 @@ export function useSendMessage({ metadata?: ExpertKickoffMetadata, ) { const trimmed = message.trim(); - const hasWorkspaceFiles = !!workspaceFiles && workspaceFiles.length > 0; - if (!trimmed && (!files || files.length === 0) && !hasWorkspaceFiles) - return; - + const workspaceParts = buildStoredAttachmentParts(workspaceFiles ?? []); + const outgoing: OutgoingMessage = { + text: trimmed, + files: files ?? [], + parts: workspaceParts, + metadata, + }; + if (!trimmed && !hasAttachments(outgoing)) return; // Backstop: the composer caps each attach, so the UI cannot reach this. - // Workspace references count too — uploaded or not, every attachment - // becomes one `file_ids` entry on the request the backend bounds. - if ( - (files?.length ?? 0) + (workspaceFiles?.length ?? 0) > - MAX_ATTACHMENTS - ) { - toast({ - title: "Too many attachments", - description: `You can attach up to ${MAX_ATTACHMENTS} attachments per message.`, - variant: "destructive", - }); - return; - } - - if (files && files.length > 0) { - const oversized = files.filter((f) => f.size > MAX_FILE_SIZE_BYTES); - if (oversized.length > 0) { - toast({ - title: "File too large", - description: `${oversized[0].name} exceeds the 100 MB limit.`, - variant: "destructive", - }); - return; - } - } + if (!validateAttachments(outgoing)) return; isUserStoppingRef.current = false; - const workspaceParts = buildStoredAttachmentParts(workspaceFiles ?? []); - if (sessionId) { const { pendingFileParts, setPendingFileParts } = useCopilotStreamStore.getState(); setPendingFileParts([]); - await dispatchToSession( - sessionId, - trimmed, - files ?? [], - [...pendingFileParts, ...workspaceParts], - metadata, - ); + await dispatchToSession(sessionId, { + ...outgoing, + parts: [...pendingFileParts, ...workspaceParts], + }); return; } @@ -272,10 +122,7 @@ export function useSendMessage({ if (files && files.length > 0) { useCopilotStreamStore .getState() - .setPendingUploadSend( - null, - describePendingUpload(trimmed, files, workspaceParts), - ); + .setPendingUploadSend(null, describePendingUpload(outgoing)); } // Workspace parts must reach the post-creation flush, which reads them // from the store via `takePendingFirstSend`. Append so a pre-set part @@ -357,26 +204,3 @@ export function recoverFailedDeferredSend( : "your files were not sent", }); } - -function describePendingUpload( - text: string, - files: File[], - prebuiltParts: StoredAttachmentPart[], -): PendingUploadSend { - const stored: PendingUploadAttachment[] = prebuiltParts.map((part) => - isFolderPart(part) - ? { name: part.data.name, mediaType: "folder", isUploading: false } - : { - name: part.filename ?? "file", - mediaType: part.mediaType, - isUploading: false, - }, - ); - const local: PendingUploadAttachment[] = files.map((file) => ({ - name: file.name, - mediaType: file.type || "application/octet-stream", - sizeBytes: file.size, - isUploading: true, - })); - return { text, attachments: [...stored, ...local] }; -} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useSettleGate.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useSettleGate.ts new file mode 100644 index 000000000000..372100d14f00 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useSettleGate.ts @@ -0,0 +1,38 @@ +import { useEffect, useRef } from "react"; + +/** + * Lets a send wait until the chat's current turn has settled. Each waiter + * learns whether it settled (send now) or the hook went away first (the + * chat host unmounted), in which case the send is dropped. + */ +export function useSettleGate(isSettled: boolean) { + const isSettledRef = useRef(isSettled); + isSettledRef.current = isSettled; + const waitersRef = useRef void>>([]); + + useEffect(() => { + if (!isSettled) return; + waitersRef.current.splice(0).forEach((resolve) => resolve(true)); + }, [isSettled]); + useEffect( + () => () => { + waitersRef.current.splice(0).forEach((resolve) => resolve(false)); + }, + [], + ); + + function waitUntilSettled() { + if (isSettledRef.current) return Promise.resolve(true); + return new Promise((resolve) => { + waitersRef.current.push(resolve); + }); + } + + /** A turn was just dispatched: waiters that arrive before the next render + * must queue behind it rather than see the stale settled state. */ + function markUnsettled() { + isSettledRef.current = false; + } + + return { waitUntilSettled, markUnsettled }; +} From 835f21e74f0ffb49eb788bd9f799f1ffa673870f Mon Sep 17 00:00:00 2001 From: abhi1992002 Date: Fri, 16 Oct 2026 05:36:36 +0000 Subject: [PATCH 3/9] fix(frontend): send attached files from the expert side-panel chat and show the message at once The side panel's onSend took only the text, so files and workspace files attached in it never reached the expert, and nothing was drawn until the session existed, which left the empty state up for seconds after Send. Every send path in the panel now goes through sendWithAttachments, so local files upload into the session and go out as file_ids with the message, in new and existing threads and behind the kickoff. The message shows from the first render after Send: held in the panel until a session exists, then as the session's upload placeholder. A follow-up with files sent while the expert is replying uploads at once and goes out as the next turn when the reply ends, because the pending buffer would store its files without their names. If the user starts a new task before it goes out, its text comes back to the composer. The composer now gets the expert's id and name, as on /copilot, so the workspace picker offers that expert's files. Co-authored-by: Claude Opus 5.5 (Claude Code) --- .../ExpertChatDrawer/ExpertChatDrawer.tsx | 8 +- .../__tests__/attachments.test.tsx | 57 ++++++- .../components/ExpertChatDrawer/helpers.ts | 9 ++ .../ExpertChatDrawer/useExpertChatDrawer.ts | 140 ++++++++++++------ .../ExpertChatDrawer/useExpertChatSend.ts | 120 +++++++++++++++ 5 files changed, 281 insertions(+), 53 deletions(-) create mode 100644 autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatSend.ts diff --git a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/ExpertChatDrawer.tsx b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/ExpertChatDrawer.tsx index 914b00510bad..14ba155fc1d5 100644 --- a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/ExpertChatDrawer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/ExpertChatDrawer.tsx @@ -120,6 +120,7 @@ function ChatPanelBody({ target, identity, chat }: BodyProps) { isLoadingSession, isCreating, suppressOnboarding, + pendingSend, } = chat; const isStreaming = status === "streaming" || status === "submitted"; @@ -133,12 +134,13 @@ function ChatPanelBody({ target, identity, chat }: BodyProps) { Opening chat… - ) : sessionId ? ( + ) : sessionId || pendingSend ? (
@@ -184,12 +187,15 @@ function ChatPanelBody({ target, identity, chat }: BodyProps) { variant="compact" onSend={onSend} disabled={isResolvingSession || isCreating} + isUploadingFiles={!!pendingSend} isStreaming={isStreaming} isTurnStarting={status === "submitted"} onStop={stop} onEnqueue={onSend} placeholder={`Message ${target.name}…`} hasSession={!!sessionId} + expertId={target.expertId} + expertName={target.name} />
diff --git a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/__tests__/attachments.test.tsx b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/__tests__/attachments.test.tsx index a3f58a72ad1a..d8562989bdf9 100644 --- a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/__tests__/attachments.test.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/__tests__/attachments.test.tsx @@ -37,6 +37,8 @@ const USER_ID = "user-1"; const EXPERT_ID = "3f8b0f7e-9f30-4a3b-a6a1-000000000001"; const SESSION_ID = "session-zara"; const FRESH_SESSION_ID = "session-zara-fresh"; +const KICKOFF_SESSION_ID = "session-zara-kickoff"; +const RESET_SESSION_ID = "session-zara-reset"; const UPLOADED_FILE_ID = "7a1c0f7e-9f30-4a3b-a6a1-0000000000aa"; const WORKSPACE_FILE_ID = "7a1c0f7e-9f30-4a3b-a6a1-0000000000bb"; @@ -143,7 +145,9 @@ function streamHandler( `${TEST_BACKEND_BASE_URL}/api/chat/sessions/${sessionId}/stream`, async ({ request }) => { const turn = recorder.streamBodies.length; - recorder.streamBodies.push(await request.clone().json()); + recorder.streamBodies.push( + (await request.clone().json()) as Record, + ); return streamSseResponse(assistantTextChunks(`Reply ${turn + 1}`), { abortSignal: request.signal, hold: holds[turn], @@ -163,11 +167,11 @@ function sessionHandler(sessionId: string) { ); } -function existingThreadList() { +function existingThreadList(sessionId = SESSION_ID) { return getGetV2ListSessionsMockHandler200({ sessions: [ { - id: SESSION_ID, + id: sessionId, created_at: "2026-09-27T18:00:00Z", updated_at: "2026-09-27T18:01:00Z", is_processing: false, @@ -280,6 +284,7 @@ describe("ExpertChatDrawer attachments", () => { mime_type: "application/pdf", size_bytes: 4096, created_at: "2026-09-27T18:00:00Z", + origin: "uploaded", }, ], has_more: false, @@ -346,6 +351,41 @@ describe("ExpertChatDrawer attachments", () => { }); }); + test("gives a follow-up waiting on the reply back when the user starts a new task", async () => { + const user = userEvent.setup(); + const recorder = newRecorder(); + const firstReply = createStreamHold(3); + server.use( + existingThreadList(RESET_SESSION_ID), + sessionHandler(RESET_SESSION_ID), + uploadHandler(recorder), + pendingHandler(recorder, RESET_SESSION_ID), + streamHandler(recorder, RESET_SESSION_ID, [firstReply.hold]), + ); + + render( {}} />); + + const input = await enabledComposer(); + await user.type(input, "hello{Enter}"); + await waitFor(() => expect(recorder.streamBodies.length).toBe(1)); + await attachLocalFile(user); + await user.type(input, "and this{Enter}"); + await screen.findByTestId("pending-upload-message"); + + fireEvent.click(screen.getByRole("button", { name: "New task" })); + firstReply.release(); + + await waitFor(() => + expect( + (screen.getByPlaceholderText("Message Zara…") as HTMLTextAreaElement) + .value, + ).toBe("and this"), + ); + expect(screen.queryByTestId("pending-upload-message")).toBeNull(); + expect(recorder.streamBodies.length).toBe(1); + expect(recorder.pendingPosts).toBe(0); + }); + test("sends a file queued behind the kickoff once the kickoff replies", async () => { const user = userEvent.setup(); const recorder = newRecorder(); @@ -362,12 +402,12 @@ describe("ExpertChatDrawer attachments", () => { ); } return HttpResponse.json( - getPostV2CreateSessionResponseMock200({ id: FRESH_SESSION_ID }), + getPostV2CreateSessionResponseMock200({ id: KICKOFF_SESSION_ID }), ); }), - sessionHandler(FRESH_SESSION_ID), + sessionHandler(KICKOFF_SESSION_ID), uploadHandler(recorder), - streamHandler(recorder, FRESH_SESSION_ID, [kickoffReply.hold]), + streamHandler(recorder, KICKOFF_SESSION_ID, [kickoffReply.hold]), ); render( @@ -383,7 +423,8 @@ describe("ExpertChatDrawer attachments", () => { await attachLocalFile(user); await user.type(input, "what is this{Enter}"); - expect(within(pendingBubble()).getByText("what is this")).toBeDefined(); + const bubble = await screen.findByTestId("pending-upload-message"); + expect(within(bubble).getByText("what is this")).toBeDefined(); await waitFor(() => expect(recorder.streamBodies.length).toBe(1)); expect(recorder.streamBodies[0].expert_kickoff).toBe(true); @@ -392,7 +433,7 @@ describe("ExpertChatDrawer attachments", () => { kickoffReply.release(); await waitFor(() => expect(recorder.streamBodies.length).toBe(2)); - expect(recorder.uploadSessionIds).toEqual([FRESH_SESSION_ID]); + expect(recorder.uploadSessionIds).toEqual([KICKOFF_SESSION_ID]); expect(recorder.streamBodies[1]).toMatchObject({ expert_kickoff: false, message: "what is this", diff --git a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/helpers.ts index d5e012880442..9dcae0a81fee 100644 --- a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/helpers.ts +++ b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/helpers.ts @@ -1,3 +1,5 @@ +import type { ExpertKickoffMetadata } from "@/app/(platform)/copilot/expertKickoff"; +import type { OutgoingMessage } from "@/app/(platform)/copilot/helpers/sendWithAttachments"; import { Expert } from "@/app/api/__generated__/models/expert"; import { AUTOPILOT_ROLE } from "../../helpers"; import { @@ -29,3 +31,10 @@ export function expertToChatTarget(expert: Expert): ChatTarget { color: expert.color, }; } + +export function textMessage( + text: string, + metadata?: ExpertKickoffMetadata, +): OutgoingMessage { + return { text, files: [], parts: [], metadata }; +} diff --git a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatDrawer.ts b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatDrawer.ts index b01381e6b4bb..4b97b6dcb947 100644 --- a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatDrawer.ts +++ b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatDrawer.ts @@ -1,7 +1,6 @@ import { buildKickoffMessage, clearKickoffPending, - type ExpertKickoffMetadata, getKickoffStatus, type KickoffAttemptToken, markKickoffDone, @@ -11,6 +10,15 @@ import { import { describeChatError } from "@/app/(platform)/copilot/chatErrorGuidance"; import { convertChatSessionMessagesToUiMessages } from "@/app/(platform)/copilot/helpers/convertChatSessionToUiMessages"; import { queueFollowUpMessage } from "@/app/(platform)/copilot/helpers/queueFollowUpMessage"; +import { + hasAttachments, + validateAttachments, + type OutgoingMessage, +} from "@/app/(platform)/copilot/helpers/sendWithAttachments"; +import { + buildStoredAttachmentParts, + type WorkspaceAttachment, +} from "@/app/(platform)/copilot/helpers/workspaceAttachments"; import { latestExpertSessionParams } from "@/app/(platform)/copilot/expertSessionQuery"; import { useCopilotPendingChips } from "@/app/(platform)/copilot/useCopilotPendingChips"; import { useCopilotStream } from "@/app/(platform)/copilot/useCopilotStream"; @@ -24,7 +32,8 @@ import { useAuthStore } from "@/lib/auth/hooks/useAuthStore"; import * as Sentry from "@sentry/nextjs"; import type { UIDataTypes, UIMessage, UITools } from "ai"; import { useEffect, useMemo, useRef, useState } from "react"; -import type { ChatTarget } from "./helpers"; +import { textMessage, type ChatTarget } from "./helpers"; +import { recoverUnsentMessage, useExpertChatSend } from "./useExpertChatSend"; type UiMessages = UIMessage[]; @@ -40,11 +49,6 @@ function notifyStartFailed(error: unknown) { }); } -interface PendingSend { - text: string; - metadata?: ExpertKickoffMetadata; -} - interface KickoffAttempt { userId: string; expertId: string; @@ -55,7 +59,7 @@ interface KickoffAttempt { * once the prompt went out, so a failed kickoff hands the failure back to * the composer or card that sent it instead of dropping the words. */ interface QueuedSend { - text: string; + message: OutgoingMessage; resolve: () => void; reject: (err: unknown) => void; } @@ -88,7 +92,7 @@ export function useExpertChatDrawer({ const [kickoffCheckedFor, setKickoffCheckedFor] = useState( null, ); - const pendingPromptRef = useRef(null); + const pendingPromptRef = useRef(null); const queuedBehindKickoffRef = useRef(null); const kickoffAttemptRef = useRef(null); // Every thread reset bumps the generation; a session create that resolves @@ -204,13 +208,16 @@ export function useExpertChatDrawer({ refetchSession: sessionQuery.refetch, copilotModel: undefined, }); + const { pendingSend, hold, release, sendNow, sendAfterReply } = + useExpertChatSend({ sessionId, status, sendMessage }); const hasAssistantReply = messages.some( (message) => message.role === "assistant", ); useEffect(() => { const attempt = kickoffAttemptRef.current; - if (!attempt || status !== "ready" || !hasAssistantReply) return; + if (!attempt || !sessionId || status !== "ready" || !hasAssistantReply) + return; kickoffAttemptRef.current = null; void withKickoffLock(attempt.userId, attempt.expertId, async () => { markKickoffDone(attempt.userId, attempt.expertId, attempt.token); @@ -220,13 +227,13 @@ export function useExpertChatDrawer({ const queued = takeQueued(); if (!queued) return; try { - await sendMessage({ text: queued.text }); + await sendNow(sessionId, queued.message); queued.resolve(); } catch (err) { queued.reject(err); } }); - }, [hasAssistantReply, sendMessage, status]); + }, [hasAssistantReply, sendNow, sessionId, status]); useEffect(() => { const attempt = kickoffAttemptRef.current; @@ -259,23 +266,28 @@ export function useExpertChatDrawer({ setKickoffCheckedFor(null); pendingPromptRef.current = null; takeQueued()?.reject(new Error("The chat was reset")); + release(); kickoffAttemptRef.current = null; setSeedToSend(seedPrompt); setSuppressOnboarding(!!seedPrompt); - }, [threadKey, seedPrompt, setMessages]); + }, [threadKey, seedPrompt, setMessages, release]); useEffect(() => { if (!seedToSend) return; setSeedToSend(null); - void startSessionRef.current({ text: seedToSend }).catch(notifyStartFailed); + void startSessionRef + .current(textMessage(seedToSend)) + .catch(notifyStartFailed); }, [seedToSend]); useEffect(() => { if (!sessionId || !pendingPromptRef.current) return; const pending = pendingPromptRef.current; pendingPromptRef.current = null; - sendMessage({ text: pending.text, metadata: pending.metadata }); - }, [sessionId, sendMessage]); + void sendNow(sessionId, pending).catch((err: unknown) => + recoverUnsentMessage(pending, err), + ); + }, [sessionId, sendNow]); function startNewThread() { setSuppressOnboarding(false); @@ -288,13 +300,14 @@ export function useExpertChatDrawer({ setKickoffCheckedFor(null); pendingPromptRef.current = null; takeQueued()?.reject(new Error("The chat was reset")); + release(); kickoffAttemptRef.current = null; } - function queueBehindKickoff(text: string): Promise { + function queueBehindKickoff(message: OutgoingMessage): Promise { if (queuedBehindKickoffRef.current) throw new SessionStartingError(); const settled = new Promise((resolve, reject) => { - queuedBehindKickoffRef.current = { text, resolve, reject }; + queuedBehindKickoffRef.current = { message, resolve, reject }; }); // The sender awaits `settled`; this handler only keeps a rejection that // lands before it does from surfacing as unhandled. @@ -312,7 +325,7 @@ export function useExpertChatDrawer({ const queued = takeQueued(); if (!queued) return; try { - const started = await startSession({ text: queued.text }); + const started = await startSession(queued.message); if (started) queued.resolve(); else queued.reject(new Error("The chat was reset")); } catch (err) { @@ -340,7 +353,8 @@ export function useExpertChatDrawer({ await sendMessage(buildKickoffMessage(id, token)); return true; } - return startSession(buildKickoffMessage(id, token), { + const kickoff = buildKickoffMessage(id, token); + return startSession(textMessage(kickoff.text, kickoff.metadata), { expertKickoff: true, }); }); @@ -361,7 +375,7 @@ export function useExpertChatDrawer({ } async function startSession( - firstMessage: PendingSend, + firstMessage: OutgoingMessage, options?: { expertKickoff?: boolean }, ): Promise { const generation = generationRef.current; @@ -405,39 +419,51 @@ export function useExpertChatDrawer({ } } - async function onSend(message: string) { - const trimmed = message.trim(); - if (!trimmed) return; - if (!sessionId && isCheckingKickoff) { - await queueBehindKickoff(trimmed); - return; - } - if ( - userId && - expertId && - kickoffCheckedFor === expertId && - getKickoffStatus(userId, expertId) === "idle" - ) { - const sent = queueBehindKickoff(trimmed); - await startKickoff(userId, expertId); - await sent; + async function onSend( + text: string, + files?: File[], + workspaceFiles?: WorkspaceAttachment[], + ) { + const message: OutgoingMessage = { + text: text.trim(), + files: files ?? [], + parts: buildStoredAttachmentParts(workspaceFiles ?? []), + }; + if (!message.text && !hasAttachments(message)) return; + if (!validateAttachments(message)) return; + if (!sessionId || needsKickoff()) { + // Nothing else draws the message until a session exists to send it to. + hold(message); + try { + await sendBeforeSession(message); + } catch (err) { + release(message); + throw err; + } return; } - if (!sessionId) { - await startSession({ text: trimmed }); + const isInFlight = status === "streaming" || status === "submitted"; + if (isInFlight && hasAttachments(message)) { + const generation = generationRef.current; + void sendAfterReply( + sessionId, + message, + () => generation === generationRef.current, + ).catch((err: unknown) => recoverUnsentMessage(message, err)); return; } - const isInFlight = status === "streaming" || status === "submitted"; if (isInFlight) { try { - await queueFollowUpMessage(sessionId, trimmed); - queueMessage(trimmed); + await queueFollowUpMessage(sessionId, message.text); + queueMessage(message.text); } catch (err) { if ( err instanceof Error && err.name === "QueueFollowUpNotActiveError" ) { - sendMessage({ text: trimmed }); + void sendNow(sessionId, message).catch((sendErr: unknown) => + recoverUnsentMessage(message, sendErr), + ); return; } Sentry.captureException(err); @@ -446,7 +472,32 @@ export function useExpertChatDrawer({ } return; } - sendMessage({ text: trimmed }); + void sendNow(sessionId, message).catch((err: unknown) => + recoverUnsentMessage(message, err), + ); + } + + function needsKickoff() { + return ( + !!userId && + !!expertId && + kickoffCheckedFor === expertId && + getKickoffStatus(userId, expertId) === "idle" + ); + } + + async function sendBeforeSession(message: OutgoingMessage) { + if (!sessionId && isCheckingKickoff) { + await queueBehindKickoff(message); + return; + } + if (needsKickoff() && userId && expertId) { + const sent = queueBehindKickoff(message); + await startKickoff(userId, expertId); + await sent; + return; + } + await startSession(message); } // Cards fail quietly and keep their form, so this path owns the toast; the @@ -481,5 +532,6 @@ export function useExpertChatDrawer({ isLoadingSession: !!sessionId && sessionQuery.isLoading, isCreating, suppressOnboarding, + pendingSend, }; } diff --git a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatSend.ts b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatSend.ts new file mode 100644 index 000000000000..d527c5972bce --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatSend.ts @@ -0,0 +1,120 @@ +import { useCopilotStreamStore } from "@/app/(platform)/copilot/copilotStreamStore"; +import { + describePendingUpload, + sendWithAttachments, + type OutgoingMessage, +} from "@/app/(platform)/copilot/helpers/sendWithAttachments"; +import type { SendInput } from "@/app/(platform)/copilot/stream/turnRuntime"; +import { recoverFailedDeferredSend } from "@/app/(platform)/copilot/useSendMessage"; +import { useSettleGate } from "@/app/(platform)/copilot/useSettleGate"; +import { useCopilotUIStore } from "@/app/(platform)/copilot/store"; +import { toast } from "@/components/molecules/Toast/use-toast"; +import * as Sentry from "@sentry/nextjs"; +import type { ChatStatus } from "ai"; +import { useRef, useState } from "react"; + +interface Args { + sessionId: string | null; + status: ChatStatus; + sendMessage: (message: SendInput) => Promise; +} + +/** + * Sends the drawer's messages through the copilot page's upload-then-send + * path, and keeps the user's message on screen from the moment they press + * Send: `hold` shows it while there is no session yet or it waits behind the + * kickoff, and the session's placeholder takes over once it is sent, for as + * long as its files upload or it waits for the reply in flight to end. + */ +export function useExpertChatSend({ sessionId, status, sendMessage }: Args) { + const [heldMessage, setHeldMessage] = useState(null); + const sendMessageRef = useRef(sendMessage); + sendMessageRef.current = sendMessage; + const { waitUntilSettled } = useSettleGate( + status === "ready" || status === "error", + ); + const placeholder = useCopilotStreamStore((s) => + sessionId ? (s.pendingUploadSends[sessionId] ?? null) : null, + ); + + function hold(message: OutgoingMessage) { + setHeldMessage((current) => current ?? message); + } + + /** Without a message, drops whatever is held (the thread was reset). */ + function release(message?: OutgoingMessage) { + setHeldMessage((current) => + !message || current === message ? null : current, + ); + } + + function latestSendMessage(input: SendInput) { + return sendMessageRef.current(input); + } + + /** Resolves once the reply to the message has streamed. */ + function sendNow(targetSessionId: string, message: OutgoingMessage) { + const sent = sendWithAttachments({ + sessionId: targetSessionId, + message, + sendMessage: latestSendMessage, + }); + // The send above put the message on screen in this same tick, as the + // placeholder or as the user's bubble, so the held copy can go. + release(message); + return sent; + } + + /** + * The pending buffer keeps a follow-up's files without their names, so the + * transcript could not show them: a follow-up with files uploads them now + * and goes out as the next turn once the reply in flight has ended. + */ + function sendAfterReply( + targetSessionId: string, + message: OutgoingMessage, + isStillCurrent: () => boolean, + ) { + return sendWithAttachments({ + sessionId: targetSessionId, + message, + sendMessage: latestSendMessage, + waitUntilReady: async () => { + if (!(await waitUntilSettled())) return false; + if (isStillCurrent()) return true; + returnToComposer(message); + return false; + }, + }); + } + + return { + pendingSend: + placeholder ?? (heldMessage ? describePendingUpload(heldMessage) : null), + hold, + release, + sendNow, + sendAfterReply, + }; +} + +/** The user started a new task while the message waited on the reply. */ +function returnToComposer(message: OutgoingMessage) { + if (message.text) useCopilotUIStore.getState().setInitialPrompt(message.text); + toast({ + title: "Follow-up not sent", + description: message.text + ? "You started a new task before it could go out. It's back in the composer." + : "You started a new task before it could go out, so its files weren't sent.", + }); +} + +/** For a send whose caller has already moved on. A kickoff prompt is ours, + * not the user's, so there is nothing to hand back for it. */ +export function recoverUnsentMessage(message: OutgoingMessage, error: unknown) { + if (message.metadata) { + Sentry.captureException(error); + return; + } + recoverFailedDeferredSend(message.text, [], error); +} From 816f693d90f216d7e951a419e9245c045a0f8dc8 Mon Sep 17 00:00:00 2001 From: abhi1992002 Date: Fri, 16 Oct 2026 06:40:51 +0000 Subject: [PATCH 4/9] fix(frontend): let the expert side panel take files while the expert replies The composer closed the attach menu, paste and workspace mentions for the whole reply, and its Queue button only carried text, so a file could not be sent mid-reply even though the drawer now holds such a message and sends it as the next turn. ChatInput takes `canAttachWhileStreaming`. With it, attaching stays open during a reply, and Queue message (like Enter) sends a message that has attachments through `onSend`. Only the side panel sets it: /copilot still refuses to queue files. Co-authored-by: Claude Opus 5.5 (Claude Code) --- .../components/ChatInput/ChatInput.tsx | 17 +++++++++-- .../ChatInput/__tests__/ChatInput.test.tsx | 29 +++++++++++++++++++ .../ExpertChatDrawer/ExpertChatDrawer.tsx | 1 + 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/ChatInput.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/ChatInput.tsx index 5c080cce91a0..86f06c1d6e17 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/ChatInput.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/ChatInput.tsx @@ -118,6 +118,9 @@ interface Props { expertId?: string | null; /** Names that expert in the picker's filter row. */ expertName?: string | null; + /** Attachments stay open during a reply, and a message with attachments + * goes to `onSend`, which must hold it until the reply ends. */ + canAttachWhileStreaming?: boolean; } export function ChatInput({ @@ -146,6 +149,7 @@ export function ChatInput({ variant = "default", expertId = null, expertName = null, + canAttachWhileStreaming = false, }: Props) { const { isDryRun, setIsDryRun } = useCopilotUIStore(); // Still the CHAT_MODE_OPTION flag, which no longer names what it gates: the @@ -184,7 +188,8 @@ export function ChatInput({ const isAtCap = attachments.length >= MAX_ATTACHMENTS; // isBusy disables non-essential interactions (attachment menu, voice recording) // but must not disable the textarea itself — streaming allows queued messages. - const isBusy = disabled || isStreaming || isUploadingFiles; + const isBusy = + disabled || isUploadingFiles || (isStreaming && !canAttachWhileStreaming); // The textarea is only truly disabled when the session is unavailable, not // during normal streaming (users can type and queue the next message). const isTextareaDisabled = disabled || isUploadingFiles; @@ -295,6 +300,9 @@ export function ChatInput({ (!!value.trim() || hasAttachments) && !isRecording && !isTranscribing; + // The pending endpoint takes text only, so attachments are queued through + // `onSend`, and only by a host that holds them until the reply ends. + const canQueue = hasAttachments ? canAttachWhileStreaming : !!onEnqueue; function handleClearGuidedPrompt() { // Only discard untouched guided prompts — never a draft the user typed @@ -560,13 +568,16 @@ export function ChatInput({ }} /> )} - {isStreaming && canSend && onEnqueue && !hasAttachments && ( + {isStreaming && canSend && canQueue && ( void handleEnqueue()} + type={hasAttachments ? "submit" : "button"} + onClick={ + hasAttachments ? undefined : () => void handleEnqueue() + } className={cn( "size-[2.625rem] rounded-full border-zinc-800 bg-zinc-800 text-white hover:border-zinc-900 hover:bg-zinc-900 disabled:border-zinc-200 disabled:bg-zinc-200 disabled:text-white disabled:opacity-100", sendButtonClass, diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/__tests__/ChatInput.test.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/__tests__/ChatInput.test.tsx index 9d5b0ba1b2b0..e17752efb92e 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/__tests__/ChatInput.test.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/__tests__/ChatInput.test.tsx @@ -543,6 +543,35 @@ describe("ChatInput Enter while streaming", () => { expect(screen.queryByLabelText(/queue message/i)).toBeNull(); }); + it("queues files attached during a reply through onSend when the host can hold them", async () => { + const onSend = vi.fn().mockResolvedValue(undefined); + const onEnqueue = vi.fn().mockResolvedValue(undefined); + render( + , + ); + const textarea = screen.getByTestId("textarea") as HTMLTextAreaElement; + fireEvent.paste(textarea, { + clipboardData: { + files: [new File(["png"], "shot.png", { type: "image/png" })], + }, + }); + fireEvent.change(textarea, { target: { value: "see attached" } }); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /queue message/i })); + }); + expect(onEnqueue).not.toHaveBeenCalled(); + expect(onSend).toHaveBeenCalledWith( + "see attached", + [expect.any(File)], + undefined, + ); + }); + it("falls back to a normal send when nothing can queue", async () => { const onSend = vi.fn().mockResolvedValue(undefined); render(); diff --git a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/ExpertChatDrawer.tsx b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/ExpertChatDrawer.tsx index 14ba155fc1d5..91fd0ce1509c 100644 --- a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/ExpertChatDrawer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/ExpertChatDrawer.tsx @@ -192,6 +192,7 @@ function ChatPanelBody({ target, identity, chat }: BodyProps) { isTurnStarting={status === "submitted"} onStop={stop} onEnqueue={onSend} + canAttachWhileStreaming placeholder={`Message ${target.name}…`} hasSession={!!sessionId} expertId={target.expertId} From 7d93976eafd28e14071459cfe8834db68c2ea09d Mon Sep 17 00:00:00 2001 From: abhi1992002 Date: Fri, 16 Oct 2026 06:40:51 +0000 Subject: [PATCH 5/9] fix(frontend): keep the side panel's held files in their own thread and through a reconnect Two ways a message with files could go wrong after Send: - It was sent with whatever `sendMessage` the panel held when the uploads finished. After New task, that belongs to the new thread, so the old task's text and files were posted to it. A send now checks that its thread is still on screen; if not, the text goes back to the composer. - A follow-up held until the reply ended was released on `status` `ready`/`error` alone. The SDK sets `status` on React's sync lane, but the stream's reconnect and post-finish probe flags render one pass later, so the send went out while the stream was reconnecting. The stream dropped it and the bubble disappeared. The panel now waits for `isReconnecting` and `isFinishProbing` too, and confirms the turn is still over once those later updates have rendered (`useNextCommit`). Tests now attach through the visible menu and send with the Queue button and Enter. They also cover a workspace file picked mid-reply, an upload that outlives its thread, and a connection dropped while a file is held. Co-authored-by: Claude Opus 5.5 (Claude Code) --- .../__tests__/attachments.test.tsx | 268 ++++++++++++++++-- .../ExpertChatDrawer/useExpertChatDrawer.ts | 41 ++- .../ExpertChatDrawer/useExpertChatSend.ts | 70 +++-- .../ExpertChatDrawer/useNextCommit.ts | 43 +++ 4 files changed, 364 insertions(+), 58 deletions(-) create mode 100644 autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useNextCommit.ts diff --git a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/__tests__/attachments.test.tsx b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/__tests__/attachments.test.tsx index d8562989bdf9..b8ce8d2848f6 100644 --- a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/__tests__/attachments.test.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/__tests__/attachments.test.tsx @@ -39,6 +39,10 @@ const SESSION_ID = "session-zara"; const FRESH_SESSION_ID = "session-zara-fresh"; const KICKOFF_SESSION_ID = "session-zara-kickoff"; const RESET_SESSION_ID = "session-zara-reset"; +const WORKSPACE_REPLY_SESSION_ID = "session-zara-workspace-reply"; +const OLD_TASK_SESSION_ID = "session-zara-old-task"; +const NEW_TASK_SESSION_ID = "session-zara-new-task"; +const DISCONNECT_SESSION_ID = "session-zara-disconnect"; const UPLOADED_FILE_ID = "7a1c0f7e-9f30-4a3b-a6a1-0000000000aa"; const WORKSPACE_FILE_ID = "7a1c0f7e-9f30-4a3b-a6a1-0000000000bb"; @@ -186,13 +190,53 @@ function photo() { return new File(["x".repeat(2048)], "photo.png", { type: "image/png" }); } +async function openAttachMenu() { + const plus = screen.getByRole("button", { + name: "Add files and more", + }) as HTMLButtonElement; + expect(plus.disabled).toBe(false); + fireEvent.pointerDown(plus, { button: 0 }); +} + async function attachLocalFile(user: ReturnType) { + await openAttachMenu(); + fireEvent.click(await screen.findByRole("menuitem", { name: /Attach file/ })); const input = document.querySelector('input[type="file"]'); if (!input) throw new Error("The composer has no file input"); await user.upload(input, photo()); await screen.findByText("photo.png"); } +async function pickWorkspaceFile() { + await openAttachMenu(); + fireEvent.click( + await screen.findByRole("menuitem", { name: /Use File from Workspace/ }), + ); + fireEvent.click(await screen.findByRole("button", { name: /brief\.pdf/ })); + fireEvent.click(screen.getByRole("button", { name: "Add 1 file" })); + await screen.findByText("brief.pdf"); +} + +function workspaceFileHandlers() { + return [ + getListWorkspaceFoldersMockHandler200({ folders: [] }), + getListWorkspaceFilesMockHandler200({ + files: [ + { + id: WORKSPACE_FILE_ID, + name: "brief.pdf", + path: "/brief.pdf", + mime_type: "application/pdf", + size_bytes: 4096, + created_at: "2026-09-27T18:00:00Z", + origin: "uploaded", + }, + ], + has_more: false, + }), + ]; +} + async function enabledComposer() { const input = (await screen.findByPlaceholderText( "Message Zara…", @@ -274,35 +318,14 @@ describe("ExpertChatDrawer attachments", () => { server.use( existingThreadList(), sessionHandler(SESSION_ID), - getListWorkspaceFoldersMockHandler200({ folders: [] }), - getListWorkspaceFilesMockHandler200({ - files: [ - { - id: WORKSPACE_FILE_ID, - name: "brief.pdf", - path: "/brief.pdf", - mime_type: "application/pdf", - size_bytes: 4096, - created_at: "2026-09-27T18:00:00Z", - origin: "uploaded", - }, - ], - has_more: false, - }), + ...workspaceFileHandlers(), streamHandler(recorder, SESSION_ID), ); render( {}} />); const input = await enabledComposer(); - fireEvent.pointerDown(screen.getByTestId("composer-plus-button"), { - button: 0, - }); - fireEvent.click( - await screen.findByRole("menuitem", { name: /Use File from Workspace/ }), - ); - fireEvent.click(await screen.findByRole("button", { name: /brief\.pdf/ })); - fireEvent.click(screen.getByRole("button", { name: "Add 1 file" })); + await pickWorkspaceFile(); await user.type(input, "summarize this{Enter}"); await waitFor(() => expect(recorder.streamBodies.length).toBe(1)); @@ -329,9 +352,11 @@ describe("ExpertChatDrawer attachments", () => { const input = await enabledComposer(); await user.type(input, "hello{Enter}"); await waitFor(() => expect(recorder.streamBodies.length).toBe(1)); + await screen.findByRole("button", { name: /Stop/ }); await attachLocalFile(user); - await user.type(input, "and this{Enter}"); + await user.type(input, "and this"); + fireEvent.click(screen.getByRole("button", { name: "Queue message" })); const bubble = await screen.findByTestId("pending-upload-message"); expect(within(bubble).getByText("and this")).toBeDefined(); @@ -440,4 +465,199 @@ describe("ExpertChatDrawer attachments", () => { file_ids: [UPLOADED_FILE_ID], }); }); + test("sends a workspace file picked mid-reply as the next turn", async () => { + const user = userEvent.setup(); + const recorder = newRecorder(); + const firstReply = createStreamHold(3); + server.use( + existingThreadList(WORKSPACE_REPLY_SESSION_ID), + sessionHandler(WORKSPACE_REPLY_SESSION_ID), + ...workspaceFileHandlers(), + pendingHandler(recorder, WORKSPACE_REPLY_SESSION_ID), + streamHandler(recorder, WORKSPACE_REPLY_SESSION_ID, [firstReply.hold]), + ); + + render( {}} />); + + const input = await enabledComposer(); + await user.type(input, "hello{Enter}"); + await screen.findByRole("button", { name: /Stop/ }); + + await pickWorkspaceFile(); + await user.type(input, "and this one{Enter}"); + + const bubble = await screen.findByTestId("pending-upload-message"); + expect(within(bubble).getByText("brief.pdf")).toBeDefined(); + expect(recorder.streamBodies.length).toBe(1); + + firstReply.release(); + + await waitFor(() => expect(recorder.streamBodies.length).toBe(2)); + expect(recorder.pendingPosts).toBe(0); + expect(recorder.streamBodies[1]).toMatchObject({ + message: "and this one", + file_ids: [WORKSPACE_FILE_ID], + }); + }); + + test("never sends a message into a new task when its upload outlives its thread", async () => { + markKickoffDone(USER_ID, EXPERT_ID, markKickoffPending(USER_ID, EXPERT_ID)); + const user = userEvent.setup(); + const recorder = newRecorder(); + const upload = deferred(); + const sentTo: string[] = []; + server.use( + existingThreadList(OLD_TASK_SESSION_ID), + sessionHandler(OLD_TASK_SESSION_ID), + sessionHandler(NEW_TASK_SESSION_ID), + http.post("/api/proxy/api/chat/sessions", () => + HttpResponse.json( + getPostV2CreateSessionResponseMock200({ id: NEW_TASK_SESSION_ID }), + ), + ), + http.post( + `${TEST_BACKEND_BASE_URL}/api/workspace/files/upload`, + async () => { + await upload.promise; + return HttpResponse.json({ + file_id: UPLOADED_FILE_ID, + name: "photo.png", + mime_type: "image/png", + }); + }, + ), + http.post( + `${TEST_BACKEND_BASE_URL}/api/chat/sessions/:sessionId/stream`, + async ({ params, request }) => { + sentTo.push(String(params.sessionId)); + recorder.streamBodies.push( + (await request.clone().json()) as Record, + ); + return streamSseResponse(assistantTextChunks("New task reply"), { + abortSignal: request.signal, + }); + }, + ), + ); + + try { + render( {}} />); + + const input = await enabledComposer(); + await attachLocalFile(user); + await user.type(input, "old task attachment{Enter}"); + await screen.findByTestId("pending-upload-message"); + + fireEvent.click(screen.getByRole("button", { name: "New task" })); + await user.type(await enabledComposer(), "new task question{Enter}"); + await screen.findByText("New task reply"); + + upload.resolve(); + + await waitFor(() => + expect( + (screen.getByPlaceholderText("Message Zara…") as HTMLTextAreaElement) + .value, + ).toBe("old task attachment"), + ); + expect(sentTo).toEqual([NEW_TASK_SESSION_ID]); + expect(recorder.streamBodies).toEqual([ + expect.objectContaining({ message: "new task question" }), + ]); + } finally { + upload.resolve(); + } + }); + + test("keeps a held file through a dropped connection and sends it once the reply has resumed and ended", async () => { + const user = userEvent.setup(); + const recorder = newRecorder(); + const disconnect = deferred(); + const resumed = createStreamHold(5); + const encoder = new TextEncoder(); + server.use( + existingThreadList(DISCONNECT_SESSION_ID), + sessionHandler(DISCONNECT_SESSION_ID), + uploadHandler(recorder), + pendingHandler(recorder, DISCONNECT_SESSION_ID), + http.post( + `${TEST_BACKEND_BASE_URL}/api/chat/sessions/${DISCONNECT_SESSION_ID}/stream`, + async ({ request }) => { + const turn = recorder.streamBodies.length; + recorder.streamBodies.push( + (await request.clone().json()) as Record, + ); + if (turn > 0) { + return streamSseResponse(assistantTextChunks("Second reply"), { + abortSignal: request.signal, + }); + } + return new HttpResponse( + new ReadableStream({ + async start(controller) { + for (const chunk of assistantTextChunks("First reply").slice( + 0, + 4, + )) { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`), + ); + } + await disconnect.promise; + controller.error(new TypeError("network failed")); + }, + }), + { + headers: { + "content-type": "text/event-stream", + "x-vercel-ai-ui-message-stream": "v1", + }, + }, + ); + }, + ), + http.get( + `${TEST_BACKEND_BASE_URL}/api/chat/sessions/${DISCONNECT_SESSION_ID}/stream`, + ({ request }) => + streamSseResponse(assistantTextChunks("Resumed reply"), { + hold: resumed.hold, + abortSignal: request.signal, + }), + ), + ); + + try { + render( {}} />); + + const input = await enabledComposer(); + await user.type(input, "hello{Enter}"); + await screen.findByRole("button", { name: /Stop/ }); + await attachLocalFile(user); + await user.type(input, "attachment after reply{Enter}"); + await screen.findByTestId("pending-upload-message"); + await waitFor(() => + expect(recorder.uploadSessionIds).toEqual([DISCONNECT_SESSION_ID]), + ); + + disconnect.resolve(); + + await screen.findByText("Resumed reply", undefined, { timeout: 5_000 }); + expect(screen.getByTestId("pending-upload-message")).toBeDefined(); + expect(recorder.streamBodies.length).toBe(1); + + resumed.release(); + + await waitFor(() => expect(recorder.streamBodies.length).toBe(2), { + timeout: 5_000, + }); + expect(recorder.pendingPosts).toBe(0); + expect(recorder.streamBodies[1]).toMatchObject({ + message: "attachment after reply", + file_ids: [UPLOADED_FILE_ID], + }); + } finally { + disconnect.resolve(); + resumed.release(); + } + }, 20_000); }); diff --git a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatDrawer.ts b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatDrawer.ts index 4b97b6dcb947..27c706682e23 100644 --- a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatDrawer.ts +++ b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatDrawer.ts @@ -199,17 +199,31 @@ export function useExpertChatDrawer({ ).messages as UiMessages; }, [sessionQuery.data, sessionId, hasActiveStream]); - const { messages, setMessages, sendMessage, stop, status, error } = - useCopilotStream({ - userId, + const { + messages, + setMessages, + sendMessage, + stop, + status, + error, + isReconnecting, + isFinishProbing, + } = useCopilotStream({ + userId, + sessionId, + hydratedMessages, + hasActiveStream, + refetchSession: sessionQuery.refetch, + copilotModel: undefined, + }); + const { pendingSend, hold, release, sendNow, sendAfterReply } = + useExpertChatSend({ sessionId, - hydratedMessages, - hasActiveStream, - refetchSession: sessionQuery.refetch, - copilotModel: undefined, + status, + isReconnecting, + isFinishProbing, + sendMessage, }); - const { pendingSend, hold, release, sendNow, sendAfterReply } = - useExpertChatSend({ sessionId, status, sendMessage }); const hasAssistantReply = messages.some( (message) => message.role === "assistant", @@ -444,12 +458,9 @@ export function useExpertChatDrawer({ } const isInFlight = status === "streaming" || status === "submitted"; if (isInFlight && hasAttachments(message)) { - const generation = generationRef.current; - void sendAfterReply( - sessionId, - message, - () => generation === generationRef.current, - ).catch((err: unknown) => recoverUnsentMessage(message, err)); + void sendAfterReply(sessionId, message).catch((err: unknown) => + recoverUnsentMessage(message, err), + ); return; } if (isInFlight) { diff --git a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatSend.ts b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatSend.ts index d527c5972bce..9bbf3f32e069 100644 --- a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatSend.ts +++ b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatSend.ts @@ -12,10 +12,13 @@ import { toast } from "@/components/molecules/Toast/use-toast"; import * as Sentry from "@sentry/nextjs"; import type { ChatStatus } from "ai"; import { useRef, useState } from "react"; +import { useNextCommit } from "./useNextCommit"; interface Args { sessionId: string | null; status: ChatStatus; + isReconnecting: boolean; + isFinishProbing: boolean; sendMessage: (message: SendInput) => Promise; } @@ -26,13 +29,28 @@ interface Args { * kickoff, and the session's placeholder takes over once it is sent, for as * long as its files upload or it waits for the reply in flight to end. */ -export function useExpertChatSend({ sessionId, status, sendMessage }: Args) { +export function useExpertChatSend({ + sessionId, + status, + isReconnecting, + isFinishProbing, + sendMessage, +}: Args) { const [heldMessage, setHeldMessage] = useState(null); + const sessionIdRef = useRef(sessionId); + sessionIdRef.current = sessionId; const sendMessageRef = useRef(sendMessage); sendMessageRef.current = sendMessage; - const { waitUntilSettled } = useSettleGate( - status === "ready" || status === "error", - ); + // The stream refuses a send while it reconnects, and a send during the + // post-finish probe can still land in the backend's pending buffer. + const isSettled = + (status === "ready" || status === "error") && + !isReconnecting && + !isFinishProbing; + const isSettledRef = useRef(isSettled); + isSettledRef.current = isSettled; + const { waitUntilSettled } = useSettleGate(isSettled); + const nextCommit = useNextCommit(); const placeholder = useCopilotStreamStore((s) => sessionId ? (s.pendingUploadSends[sessionId] ?? null) : null, ); @@ -52,12 +70,33 @@ export function useExpertChatSend({ sessionId, status, sendMessage }: Args) { return sendMessageRef.current(input); } + /** Resolves `false` when the drawer closed first. */ + async function waitForReplyToEnd() { + while (await waitUntilSettled()) { + if (!(await nextCommit())) return false; + if (isSettledRef.current) return true; + } + return false; + } + + /** `latestSendMessage` sends into the thread on screen, so a message whose + * uploads outlived its own thread goes back to the composer instead. */ + function isStillShown(targetSessionId: string, message: OutgoingMessage) { + if (sessionIdRef.current === targetSessionId) return true; + returnToComposer(message); + return false; + } + /** Resolves once the reply to the message has streamed. */ function sendNow(targetSessionId: string, message: OutgoingMessage) { const sent = sendWithAttachments({ sessionId: targetSessionId, message, sendMessage: latestSendMessage, + waitUntilReady: + message.files.length > 0 + ? async () => isStillShown(targetSessionId, message) + : undefined, }); // The send above put the message on screen in this same tick, as the // placeholder or as the user's bubble, so the held copy can go. @@ -70,21 +109,13 @@ export function useExpertChatSend({ sessionId, status, sendMessage }: Args) { * transcript could not show them: a follow-up with files uploads them now * and goes out as the next turn once the reply in flight has ended. */ - function sendAfterReply( - targetSessionId: string, - message: OutgoingMessage, - isStillCurrent: () => boolean, - ) { + function sendAfterReply(targetSessionId: string, message: OutgoingMessage) { return sendWithAttachments({ sessionId: targetSessionId, message, sendMessage: latestSendMessage, - waitUntilReady: async () => { - if (!(await waitUntilSettled())) return false; - if (isStillCurrent()) return true; - returnToComposer(message); - return false; - }, + waitUntilReady: async () => + (await waitForReplyToEnd()) && isStillShown(targetSessionId, message), }); } @@ -98,14 +129,15 @@ export function useExpertChatSend({ sessionId, status, sendMessage }: Args) { }; } -/** The user started a new task while the message waited on the reply. */ +/** The user started a new task while the message's files uploaded or it + * waited on the reply. */ function returnToComposer(message: OutgoingMessage) { if (message.text) useCopilotUIStore.getState().setInitialPrompt(message.text); toast({ - title: "Follow-up not sent", + title: "Message not sent", description: message.text - ? "You started a new task before it could go out. It's back in the composer." - : "You started a new task before it could go out, so its files weren't sent.", + ? "You started a new task before it went out. Its text is back in the composer; attach the files again." + : "You started a new task before it went out. Attach the files again to send them.", }); } diff --git a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useNextCommit.ts b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useNextCommit.ts new file mode 100644 index 000000000000..7ae877e36c0b --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useNextCommit.ts @@ -0,0 +1,43 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * `nextCommit()` resolves once React has committed every state update queued + * before the call: `true` when it has, `false` when the host unmounted first. + * + * The chat SDK flips `status` on React's sync lane, while the stream's own + * flags (a scheduled reconnect, the post-finish probe) are plain state updates + * that render one pass later. A check made on the `status` render alone sees + * a turn as over while the stream still refuses to send. + */ +export function useNextCommit() { + const [committed, setCommitted] = useState(0); + const requestedRef = useRef(0); + const waitersRef = useRef>([]); + + useEffect(() => { + waitersRef.current = waitersRef.current.filter((waiter) => { + if (waiter.ticket > committed) return true; + waiter.resolve(true); + return false; + }); + }, [committed]); + useEffect( + () => () => { + waitersRef.current.splice(0).forEach((waiter) => waiter.resolve(false)); + }, + [], + ); + + function nextCommit() { + requestedRef.current += 1; + const ticket = requestedRef.current; + return new Promise((resolve) => { + waitersRef.current.push({ ticket, resolve }); + setCommitted((count) => Math.max(count, ticket)); + }); + } + + return nextCommit; +} + +type Resolve = (committed: boolean) => void; From 918b71459a4f605d28a8ade6bf38fcaf396e7dda Mon Sep 17 00:00:00 2001 From: abhi1992002 Date: Fri, 16 Oct 2026 07:32:17 +0000 Subject: [PATCH 6/9] test(frontend): side-panel files sent during a reconnect, after the panel closes, and over a new draft Three ways the expert side panel still loses or locks a message with files: - a file sent while the connection is being restored is cleared from the screen and never sent; - closing the panel while a file uploads leaves the thread's composer locked when it is opened again, with or without a reply in flight; - a message sent back to the composer after New task replaces what the user has typed in the new task. Co-authored-by: Claude Opus 5.5 (Claude Code) --- .../__tests__/attachments.test.tsx | 187 +++++++++++++++++- 1 file changed, 182 insertions(+), 5 deletions(-) diff --git a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/__tests__/attachments.test.tsx b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/__tests__/attachments.test.tsx index b8ce8d2848f6..ba06605af671 100644 --- a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/__tests__/attachments.test.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/__tests__/attachments.test.tsx @@ -43,6 +43,8 @@ const WORKSPACE_REPLY_SESSION_ID = "session-zara-workspace-reply"; const OLD_TASK_SESSION_ID = "session-zara-old-task"; const NEW_TASK_SESSION_ID = "session-zara-new-task"; const DISCONNECT_SESSION_ID = "session-zara-disconnect"; +const RECONNECT_SEND_SESSION_ID = "session-zara-reconnect-send"; +const CLOSED_SESSION_ID = "session-zara-closed"; const UPLOADED_FILE_ID = "7a1c0f7e-9f30-4a3b-a6a1-0000000000aa"; const WORKSPACE_FILE_ID = "7a1c0f7e-9f30-4a3b-a6a1-0000000000bb"; @@ -549,16 +551,17 @@ describe("ExpertChatDrawer attachments", () => { await screen.findByTestId("pending-upload-message"); fireEvent.click(screen.getByRole("button", { name: "New task" })); - await user.type(await enabledComposer(), "new task question{Enter}"); + const newTaskInput = await enabledComposer(); + await user.type(newTaskInput, "new task question{Enter}"); await screen.findByText("New task reply"); + await user.type(newTaskInput, "new unsent draft"); upload.resolve(); await waitFor(() => - expect( - (screen.getByPlaceholderText("Message Zara…") as HTMLTextAreaElement) - .value, - ).toBe("old task attachment"), + expect(newTaskInput.value).toBe( + "old task attachment\n\nnew unsent draft", + ), ); expect(sentTo).toEqual([NEW_TASK_SESSION_ID]); expect(recorder.streamBodies).toEqual([ @@ -660,4 +663,178 @@ describe("ExpertChatDrawer attachments", () => { resumed.release(); } }, 20_000); + + test("holds a file sent while the connection is being restored and sends it once the resumed reply ends", async () => { + const user = userEvent.setup(); + const recorder = newRecorder(); + const disconnect = deferred(); + const resumed = createStreamHold(5); + const encoder = new TextEncoder(); + server.use( + existingThreadList(RECONNECT_SEND_SESSION_ID), + sessionHandler(RECONNECT_SEND_SESSION_ID), + uploadHandler(recorder), + pendingHandler(recorder, RECONNECT_SEND_SESSION_ID), + http.post( + `${TEST_BACKEND_BASE_URL}/api/chat/sessions/${RECONNECT_SEND_SESSION_ID}/stream`, + async ({ request }) => { + const turn = recorder.streamBodies.length; + recorder.streamBodies.push( + (await request.clone().json()) as Record, + ); + if (turn > 0) { + return streamSseResponse(assistantTextChunks("Second reply"), { + abortSignal: request.signal, + }); + } + return new HttpResponse( + new ReadableStream({ + async start(controller) { + for (const chunk of assistantTextChunks("First reply").slice( + 0, + 4, + )) { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`), + ); + } + await disconnect.promise; + controller.error(new TypeError("network failed")); + }, + }), + { + headers: { + "content-type": "text/event-stream", + "x-vercel-ai-ui-message-stream": "v1", + }, + }, + ); + }, + ), + http.get( + `${TEST_BACKEND_BASE_URL}/api/chat/sessions/${RECONNECT_SEND_SESSION_ID}/stream`, + ({ request }) => + streamSseResponse(assistantTextChunks("Resumed reply"), { + hold: resumed.hold, + abortSignal: request.signal, + }), + ), + ); + + try { + render( {}} />); + + const input = await enabledComposer(); + await user.type(input, "hello{Enter}"); + await screen.findByRole("button", { name: /Stop/ }); + await attachLocalFile(user); + await user.type(input, "sent while reconnecting"); + + disconnect.resolve(); + await waitFor(() => + expect(screen.queryByRole("button", { name: /Stop/ })).toBeNull(), + ); + await user.type(input, "{Enter}"); + + await waitFor(() => + expect(recorder.uploadSessionIds).toEqual([RECONNECT_SEND_SESSION_ID]), + ); + await screen.findByText("Resumed reply", undefined, { timeout: 5_000 }); + expect(pendingBubble().textContent).toContain("sent while reconnecting"); + expect(recorder.streamBodies).toEqual([ + expect.objectContaining({ message: "hello" }), + ]); + + resumed.release(); + + await waitFor(() => expect(recorder.streamBodies.length).toBe(2), { + timeout: 5_000, + }); + expect(recorder.pendingPosts).toBe(0); + expect(recorder.streamBodies[1]).toMatchObject({ + message: "sent while reconnecting", + file_ids: [UPLOADED_FILE_ID], + }); + } finally { + disconnect.resolve(); + resumed.release(); + } + }, 20_000); + + test.each([ + { when: "while the expert replies", replying: true }, + { when: "with no reply in flight", replying: false }, + ])( + "leaves the chat usable when the panel closes during an upload $when", + async ({ replying }) => { + markKickoffDone( + USER_ID, + EXPERT_ID, + markKickoffPending(USER_ID, EXPERT_ID), + ); + const user = userEvent.setup(); + const recorder = newRecorder(); + const upload = deferred(); + const reply = createStreamHold(3); + let uploadReturned = false; + server.use( + existingThreadList(CLOSED_SESSION_ID), + sessionHandler(CLOSED_SESSION_ID), + streamHandler(recorder, CLOSED_SESSION_ID, [reply.hold]), + http.post( + `${TEST_BACKEND_BASE_URL}/api/workspace/files/upload`, + async () => { + recorder.uploadSessionIds.push(CLOSED_SESSION_ID); + await upload.promise; + uploadReturned = true; + return HttpResponse.json({ + file_id: UPLOADED_FILE_ID, + name: "photo.png", + mime_type: "image/png", + }); + }, + ), + ); + + try { + const view = render( + {}} />, + ); + const input = await enabledComposer(); + if (replying) { + await user.type(input, "hello{Enter}"); + await screen.findByRole("button", { name: /Stop/ }); + } + await attachLocalFile(user); + await user.type(input, "held while the upload runs{Enter}"); + await screen.findByTestId("pending-upload-message"); + await waitFor(() => + expect(recorder.uploadSessionIds).toEqual([CLOSED_SESSION_ID]), + ); + + view.unmount(); + upload.resolve(); + await waitFor(() => expect(uploadReturned).toBe(true)); + reply.release(); + + render( {}} />); + + await waitFor(() => + expect( + useCopilotStreamStore.getState().pendingUploadSends[ + CLOSED_SESSION_ID + ], + ).toBeUndefined(), + ); + await enabledComposer(); + expect(screen.queryByTestId("pending-upload-message")).toBeNull(); + expect(recorder.streamBodies).toEqual( + replying ? [expect.objectContaining({ message: "hello" })] : [], + ); + } finally { + upload.resolve(); + reply.release(); + } + }, + ); }); From b9cf15078e03f2fa51587ff79abc0a8102248c70 Mon Sep 17 00:00:00 2001 From: abhi1992002 Date: Fri, 16 Oct 2026 07:32:17 +0000 Subject: [PATCH 7/9] fix(frontend): hold side-panel files through a reconnect, a closed panel and a new draft Every message with files now goes out through one send that waits until the reply in flight has ended. Before, that wait only applied when the SDK said a reply was streaming. A message sent while the connection was being restored, or right after a kickoff reply, skipped it, and the stream then refused the send without telling its caller. The settle gate and the commit barrier return false once their host has unmounted. An upload that finished after the panel closed used to wait on them forever, so the thread's placeholder stayed and its composer stayed locked. A message the panel hands back goes into a new recovered-draft slot. The composer adds it to what the user has typed instead of replacing it. Guided prompts keep the replace behaviour of `initialPrompt`. Co-authored-by: Claude Opus 5.5 (Claude Code) --- .../components/ChatInput/useChatInput.ts | 18 ++++++- .../src/app/(platform)/copilot/store.ts | 15 ++++++ .../app/(platform)/copilot/useSettleGate.ts | 15 ++++-- .../ExpertChatDrawer/useExpertChatDrawer.ts | 33 ++++++------ .../ExpertChatDrawer/useExpertChatSend.ts | 52 +++++++++---------- .../ExpertChatDrawer/useNextCommit.ts | 13 +++-- 6 files changed, 91 insertions(+), 55 deletions(-) diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/useChatInput.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/useChatInput.ts index aae40d8533be..4d2053080aaa 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/useChatInput.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/useChatInput.ts @@ -36,8 +36,13 @@ export function useChatInput({ // unlike state which batches and can leave a gap for a second call. const isSubmittingRef = useRef(false); const isEnqueueingRef = useRef(false); - const { initialPrompt, setInitialPrompt, notifyMessageSent } = - useCopilotUIStore(); + const { + initialPrompt, + setInitialPrompt, + recoveredDraft, + clearRecoveredDraft, + notifyMessageSent, + } = useCopilotUIStore(); useEffect( function consumeInitialPrompt() { @@ -57,6 +62,15 @@ export function useChatInput({ [initialPrompt, setInitialPrompt, inputId, setValue], ); + useEffect( + function consumeRecoveredDraft() { + if (!recoveredDraft) return; + setValue((current) => restoreFailedDraft(current, recoveredDraft)); + clearRecoveredDraft(); + }, + [recoveredDraft, clearRecoveredDraft, setValue], + ); + useEffect( function focusOnMount() { const textarea = document.getElementById(inputId) as HTMLTextAreaElement; diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/store.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/store.ts index 82c29d9aa947..22a2af23c5be 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/store.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/store.ts @@ -173,6 +173,12 @@ interface CopilotUIState { initialPrompt: string | null; setInitialPrompt: (prompt: string | null) => void; + /** Text of a message that never went out. Unlike `initialPrompt`, the + * composer adds it to whatever the user has typed since. */ + recoveredDraft: string | null; + restoreDraft: (text: string) => void; + clearRecoveredDraft: () => void; + /** * Bumped every time the composer actually sends a message. Chain action * cards draft into the input rather than sending themselves, so this is @@ -295,6 +301,15 @@ export const useCopilotUIStore = create((set, get) => ({ initialPrompt: null, setInitialPrompt: (prompt) => set({ initialPrompt: prompt }), + recoveredDraft: null, + restoreDraft: (text) => + set((state) => ({ + recoveredDraft: state.recoveredDraft + ? `${state.recoveredDraft}\n\n${text}` + : text, + })), + clearRecoveredDraft: () => set({ recoveredDraft: null }), + sentMessageCount: 0, notifyMessageSent: () => set((state) => ({ sentMessageCount: state.sentMessageCount + 1 })), diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useSettleGate.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useSettleGate.ts index 372100d14f00..89c4b6afd236 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useSettleGate.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useSettleGate.ts @@ -9,19 +9,24 @@ export function useSettleGate(isSettled: boolean) { const isSettledRef = useRef(isSettled); isSettledRef.current = isSettled; const waitersRef = useRef void>>([]); + const isMountedRef = useRef(true); useEffect(() => { if (!isSettled) return; waitersRef.current.splice(0).forEach((resolve) => resolve(true)); }, [isSettled]); - useEffect( - () => () => { + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; waitersRef.current.splice(0).forEach((resolve) => resolve(false)); - }, - [], - ); + }; + }, []); + /** A send still uploading when the host unmounted asks only afterwards, + * when nothing is left to resolve it. */ function waitUntilSettled() { + if (!isMountedRef.current) return Promise.resolve(false); if (isSettledRef.current) return Promise.resolve(true); return new Promise((resolve) => { waitersRef.current.push(resolve); diff --git a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatDrawer.ts b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatDrawer.ts index 27c706682e23..89e0fbc27d38 100644 --- a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatDrawer.ts +++ b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatDrawer.ts @@ -216,14 +216,13 @@ export function useExpertChatDrawer({ refetchSession: sessionQuery.refetch, copilotModel: undefined, }); - const { pendingSend, hold, release, sendNow, sendAfterReply } = - useExpertChatSend({ - sessionId, - status, - isReconnecting, - isFinishProbing, - sendMessage, - }); + const { pendingSend, hold, release, send } = useExpertChatSend({ + sessionId, + status, + isReconnecting, + isFinishProbing, + sendMessage, + }); const hasAssistantReply = messages.some( (message) => message.role === "assistant", @@ -241,13 +240,13 @@ export function useExpertChatDrawer({ const queued = takeQueued(); if (!queued) return; try { - await sendNow(sessionId, queued.message); + await send(sessionId, queued.message); queued.resolve(); } catch (err) { queued.reject(err); } }); - }, [hasAssistantReply, sendNow, sessionId, status]); + }, [hasAssistantReply, send, sessionId, status]); useEffect(() => { const attempt = kickoffAttemptRef.current; @@ -298,10 +297,10 @@ export function useExpertChatDrawer({ if (!sessionId || !pendingPromptRef.current) return; const pending = pendingPromptRef.current; pendingPromptRef.current = null; - void sendNow(sessionId, pending).catch((err: unknown) => + void send(sessionId, pending).catch((err: unknown) => recoverUnsentMessage(pending, err), ); - }, [sessionId, sendNow]); + }, [sessionId, send]); function startNewThread() { setSuppressOnboarding(false); @@ -456,13 +455,13 @@ export function useExpertChatDrawer({ } return; } - const isInFlight = status === "streaming" || status === "submitted"; - if (isInFlight && hasAttachments(message)) { - void sendAfterReply(sessionId, message).catch((err: unknown) => + if (hasAttachments(message)) { + void send(sessionId, message).catch((err: unknown) => recoverUnsentMessage(message, err), ); return; } + const isInFlight = status === "streaming" || status === "submitted"; if (isInFlight) { try { await queueFollowUpMessage(sessionId, message.text); @@ -472,7 +471,7 @@ export function useExpertChatDrawer({ err instanceof Error && err.name === "QueueFollowUpNotActiveError" ) { - void sendNow(sessionId, message).catch((sendErr: unknown) => + void send(sessionId, message).catch((sendErr: unknown) => recoverUnsentMessage(message, sendErr), ); return; @@ -483,7 +482,7 @@ export function useExpertChatDrawer({ } return; } - void sendNow(sessionId, message).catch((err: unknown) => + void send(sessionId, message).catch((err: unknown) => recoverUnsentMessage(message, err), ); } diff --git a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatSend.ts b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatSend.ts index 9bbf3f32e069..fe223729fec2 100644 --- a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatSend.ts +++ b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatSend.ts @@ -1,11 +1,12 @@ import { useCopilotStreamStore } from "@/app/(platform)/copilot/copilotStreamStore"; import { describePendingUpload, + hasAttachments, sendWithAttachments, type OutgoingMessage, } from "@/app/(platform)/copilot/helpers/sendWithAttachments"; import type { SendInput } from "@/app/(platform)/copilot/stream/turnRuntime"; -import { recoverFailedDeferredSend } from "@/app/(platform)/copilot/useSendMessage"; +import { toastSendFailure } from "@/app/(platform)/copilot/helpers/sendFailureToast"; import { useSettleGate } from "@/app/(platform)/copilot/useSettleGate"; import { useCopilotUIStore } from "@/app/(platform)/copilot/store"; import { toast } from "@/components/molecules/Toast/use-toast"; @@ -87,16 +88,24 @@ export function useExpertChatSend({ return false; } - /** Resolves once the reply to the message has streamed. */ - function sendNow(targetSessionId: string, message: OutgoingMessage) { + /** + * Resolves once the reply to the message has streamed. A message with + * files, or with workspace files, goes out only once the turn in flight has + * ended: the pending buffer keeps a follow-up's files without their names, + * so the transcript could not show them, and the stream refuses a send + * while it reconnects without saying so to its caller. + */ + function send(targetSessionId: string, message: OutgoingMessage) { const sent = sendWithAttachments({ sessionId: targetSessionId, message, sendMessage: latestSendMessage, - waitUntilReady: - message.files.length > 0 - ? async () => isStillShown(targetSessionId, message) - : undefined, + waitUntilReady: hasAttachments(message) + ? async () => + isStillShown(targetSessionId, message) && + (await waitForReplyToEnd()) && + isStillShown(targetSessionId, message) + : undefined, }); // The send above put the message on screen in this same tick, as the // placeholder or as the user's bubble, so the held copy can go. @@ -104,35 +113,19 @@ export function useExpertChatSend({ return sent; } - /** - * The pending buffer keeps a follow-up's files without their names, so the - * transcript could not show them: a follow-up with files uploads them now - * and goes out as the next turn once the reply in flight has ended. - */ - function sendAfterReply(targetSessionId: string, message: OutgoingMessage) { - return sendWithAttachments({ - sessionId: targetSessionId, - message, - sendMessage: latestSendMessage, - waitUntilReady: async () => - (await waitForReplyToEnd()) && isStillShown(targetSessionId, message), - }); - } - return { pendingSend: placeholder ?? (heldMessage ? describePendingUpload(heldMessage) : null), hold, release, - sendNow, - sendAfterReply, + send, }; } /** The user started a new task while the message's files uploaded or it * waited on the reply. */ function returnToComposer(message: OutgoingMessage) { - if (message.text) useCopilotUIStore.getState().setInitialPrompt(message.text); + if (message.text) useCopilotUIStore.getState().restoreDraft(message.text); toast({ title: "Message not sent", description: message.text @@ -148,5 +141,12 @@ export function recoverUnsentMessage(message: OutgoingMessage, error: unknown) { Sentry.captureException(error); return; } - recoverFailedDeferredSend(message.text, [], error); + if (message.text) useCopilotUIStore.getState().restoreDraft(message.text); + toastSendFailure({ + title: "Couldn't send message", + error, + recovery: message.text + ? "your message is back in the composer" + : "your files were not sent", + }); } diff --git a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useNextCommit.ts b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useNextCommit.ts index 7ae877e36c0b..18662831cdbd 100644 --- a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useNextCommit.ts +++ b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useNextCommit.ts @@ -13,6 +13,7 @@ export function useNextCommit() { const [committed, setCommitted] = useState(0); const requestedRef = useRef(0); const waitersRef = useRef>([]); + const isMountedRef = useRef(true); useEffect(() => { waitersRef.current = waitersRef.current.filter((waiter) => { @@ -21,14 +22,16 @@ export function useNextCommit() { return false; }); }, [committed]); - useEffect( - () => () => { + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; waitersRef.current.splice(0).forEach((waiter) => waiter.resolve(false)); - }, - [], - ); + }; + }, []); function nextCommit() { + if (!isMountedRef.current) return Promise.resolve(false); requestedRef.current += 1; const ticket = requestedRef.current; return new Promise((resolve) => { From 99e4c742aae33e8a00d1cd55df533cb0a4a70eef Mon Sep 17 00:00:00 2001 From: abhi1992002 Date: Fri, 16 Oct 2026 08:19:43 +0000 Subject: [PATCH 8/9] fix(frontend): send a message with files whose caption repeats the last one The duplicate-send guard compared only text, so a held side-panel follow-up with a file and the same caption as the previous message was dropped as a resend. The guard now also compares the attachments. Co-authored-by: Claude Opus 5.5 (Claude Code) --- .../copilot/__tests__/helpers.test.ts | 43 +++++++++++++++++++ .../src/app/(platform)/copilot/helpers.ts | 43 +++++++++++++++++-- .../(platform)/copilot/useCopilotStream.ts | 2 + 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/helpers.test.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/helpers.test.ts index 7fbb4586c7cf..01820cb7980b 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/helpers.test.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/helpers.test.ts @@ -8,6 +8,7 @@ import { extractSendMessageText, formatNotificationTitle, getCopilotAuthHeaders, + extractSendMessageAttachments, getSendSuppressionReason, hasVisibleAssistantContent, parseSessionIDs, @@ -365,6 +366,48 @@ describe("getSendSuppressionReason", () => { ).toBe("duplicate"); }); + it("lets the same caption through when it carries other attachments", () => { + const messages = [ + makeMsg("user", "summarize this"), + makeMsg("assistant", "ok"), + ]; + expect( + getSendSuppressionReason({ + text: "summarize this", + attachments: extractSendMessageAttachments({ + text: "summarize this", + files: [{ type: "file", mediaType: "image/png", url: "/f/new" }], + }), + isReconnectScheduled: false, + lastSubmittedText: "summarize this", + messages, + }), + ).toBeNull(); + }); + + it("still drops a resend of the same caption with the same attachments", () => { + const file = { type: "file" as const, mediaType: "image/png", url: "/f/1" }; + const messages: UIMessage[] = [ + { + id: "msg-with-file", + role: "user", + parts: [file, { type: "text", text: "summarize this" }], + }, + ]; + expect( + getSendSuppressionReason({ + text: "summarize this", + attachments: extractSendMessageAttachments({ + text: "summarize this", + files: [file], + }), + isReconnectScheduled: false, + lastSubmittedText: "summarize this", + messages, + }), + ).toBe("duplicate"); + }); + it("returns null when text matches last submitted but differs from last user message", () => { const messages = [ makeMsg("user", "different"), diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts index c339be8b3ad6..822b6b09badc 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts @@ -5,6 +5,7 @@ import type { ChatStatus, UIMessage } from "ai"; import { deleteV2DisconnectSessionStream } from "@/app/api/__generated__/endpoints/chat/chat"; import { TOOL_PART_PREFIX } from "./components/JobStatsBar/constants"; import { parseSpecialMarkers } from "./helpers/messageMarkers"; +import { WORKSPACE_FOLDER_PART_TYPE } from "./helpers/workspaceAttachments"; import { TURN_PAUSED_PART_TYPE } from "./turnPause"; export const ORIGINAL_TITLE = "AutoGPT"; @@ -236,8 +237,36 @@ export function extractSendMessageText(firstArg: unknown): string { return String(firstArg ?? ""); } +/** What the files and folders passed to `sendMessage` point at, sorted, so + * two sends can be told apart when their text is the same. */ +export function extractSendMessageAttachments(firstArg: unknown): string[] { + if (!firstArg || typeof firstArg !== "object") return []; + if ("files" in firstArg && Array.isArray(firstArg.files)) + return attachmentKeys(firstArg.files); + if ("parts" in firstArg && Array.isArray(firstArg.parts)) + return attachmentKeys(firstArg.parts); + return []; +} + +function attachmentKeys(parts: readonly unknown[]): string[] { + return parts + .flatMap((part) => { + if (!part || typeof part !== "object" || !("type" in part)) return []; + if (part.type === "file" && "url" in part) + return typeof part.url === "string" ? [part.url] : []; + if (part.type === WORKSPACE_FOLDER_PART_TYPE && "data" in part) { + const data = part.data as { id?: unknown } | null; + return typeof data?.id === "string" ? [`folder:${data.id}`] : []; + } + return []; + }) + .sort(); +} + interface SuppressDuplicateArgs { text: string; + /** The outgoing message's attachments, from `extractSendMessageAttachments`. */ + attachments?: string[]; isReconnectScheduled: boolean; lastSubmittedText: string | null; messages: UIMessage[]; @@ -249,8 +278,9 @@ interface SuppressDuplicateArgs { * * - ``"reconnecting"``: the stream is reconnecting; the caller should * notify the user (the UI may not yet reflect the disabled state). - * - ``"duplicate"``: the same text was just submitted and echoed back - * by the session — safe to silently drop (user double-clicked). + * - ``"duplicate"``: the same text, with the same attachments, was just + * submitted and echoed back by the session — safe to silently drop + * (user double-clicked). */ export type SuppressReason = "reconnecting" | "duplicate" | null; @@ -263,6 +293,7 @@ export type SuppressReason = "reconnecting" | "duplicate" | null; */ export function getSendSuppressionReason({ text, + attachments = [], isReconnectScheduled, lastSubmittedText, messages, @@ -287,7 +318,13 @@ export function getSendSuppressionReason({ ?.map((p) => ("text" in p ? p.text : "")) .join("") .trim(); - if (lastUserText === text) return "duplicate"; + // The same caption on other files is a new message, not a resend. + const lastUserAttachments = attachmentKeys(lastUserMsg?.parts ?? []); + if ( + lastUserText === text && + lastUserAttachments.join("\n") === attachments.join("\n") + ) + return "duplicate"; } return null; diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotStream.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotStream.ts index 84c435639b65..32a76caa638c 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotStream.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotStream.ts @@ -26,6 +26,7 @@ import { import { deduplicateMessages, extractSendMessageText, + extractSendMessageAttachments, getSendSuppressionReason, hasActiveBackendStream, hasInProgressAssistantParts, @@ -537,6 +538,7 @@ export function useCopilotStream({ const suppressReason = getSendSuppressionReason({ text, + attachments: extractSendMessageAttachments(args[0]), isReconnectScheduled: reconnectScheduledRef.current, lastSubmittedText: coord?.lastSubmittedMessageText ?? null, messages: chatRuntime?.chat.messages ?? rawMessages, From 2080f3787beee6505e42879d381b459a88ed0245 Mon Sep 17 00:00:00 2001 From: abhi1992002 Date: Fri, 16 Oct 2026 08:19:43 +0000 Subject: [PATCH 9/9] fix(frontend): drop a held side-panel follow-up when the reply is stopped Stop ended the reply and the held message with files went out right after, while the backend discards buffered text follow-ups on cancel. Now Stop cancels it, puts its text back in the composer and says why. Co-authored-by: Claude Opus 5.5 (Claude Code) --- .../__tests__/attachments.test.tsx | 88 +++++++++++++++++++ .../ExpertChatDrawer/useExpertChatDrawer.ts | 5 +- .../ExpertChatDrawer/useExpertChatSend.ts | 41 +++++++-- 3 files changed, 125 insertions(+), 9 deletions(-) diff --git a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/__tests__/attachments.test.tsx b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/__tests__/attachments.test.tsx index ba06605af671..e033baefda8f 100644 --- a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/__tests__/attachments.test.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/__tests__/attachments.test.tsx @@ -8,6 +8,7 @@ import { getGetV2GetSessionMockHandler200, getGetV2GetSessionResponseMock200, getGetV2ListSessionsMockHandler200, + getPostV2CancelSessionTaskMockHandler, getPostV2CreateSessionResponseMock200, } from "@/app/api/__generated__/endpoints/chat/chat.msw"; import { @@ -45,6 +46,8 @@ const NEW_TASK_SESSION_ID = "session-zara-new-task"; const DISCONNECT_SESSION_ID = "session-zara-disconnect"; const RECONNECT_SEND_SESSION_ID = "session-zara-reconnect-send"; const CLOSED_SESSION_ID = "session-zara-closed"; +const SAME_CAPTION_SESSION_ID = "session-zara-same-caption"; +const STOP_SESSION_ID = "session-zara-stop"; const UPLOADED_FILE_ID = "7a1c0f7e-9f30-4a3b-a6a1-0000000000aa"; const WORKSPACE_FILE_ID = "7a1c0f7e-9f30-4a3b-a6a1-0000000000bb"; @@ -378,6 +381,91 @@ describe("ExpertChatDrawer attachments", () => { }); }); + test("sends a file held mid-reply even when its caption repeats the last message", async () => { + const user = userEvent.setup(); + const recorder = newRecorder(); + const firstReply = createStreamHold(3); + server.use( + existingThreadList(SAME_CAPTION_SESSION_ID), + sessionHandler(SAME_CAPTION_SESSION_ID), + uploadHandler(recorder), + pendingHandler(recorder, SAME_CAPTION_SESSION_ID), + streamHandler(recorder, SAME_CAPTION_SESSION_ID, [firstReply.hold]), + ); + + render( {}} />); + + const input = await enabledComposer(); + await user.type(input, "summarize this{Enter}"); + await waitFor(() => expect(recorder.streamBodies.length).toBe(1)); + await screen.findByRole("button", { name: /Stop/ }); + + await attachLocalFile(user); + await user.type(input, "summarize this"); + fireEvent.click(screen.getByRole("button", { name: "Queue message" })); + await waitFor(() => + expect(recorder.uploadSessionIds).toEqual([SAME_CAPTION_SESSION_ID]), + ); + + firstReply.release(); + + await waitFor(() => expect(recorder.streamBodies.length).toBe(2)); + expect(recorder.streamBodies[1]).toMatchObject({ + message: "summarize this", + file_ids: [UPLOADED_FILE_ID], + }); + }); + + test("gives a follow-up waiting on the reply back when the user stops the reply", async () => { + const user = userEvent.setup(); + const recorder = newRecorder(); + const firstReply = createStreamHold(3); + let cancels = 0; + server.use( + existingThreadList(STOP_SESSION_ID), + sessionHandler(STOP_SESSION_ID), + uploadHandler(recorder), + pendingHandler(recorder, STOP_SESSION_ID), + streamHandler(recorder, STOP_SESSION_ID, [firstReply.hold]), + getPostV2CancelSessionTaskMockHandler(() => { + cancels += 1; + return { cancelled: true }; + }), + ); + + render( {}} />); + + const input = await enabledComposer(); + await user.type(input, "hello{Enter}"); + await waitFor(() => expect(recorder.streamBodies.length).toBe(1)); + await attachLocalFile(user); + await user.type(input, "and this"); + fireEvent.click(screen.getByRole("button", { name: "Queue message" })); + await waitFor(() => + expect(recorder.uploadSessionIds).toEqual([STOP_SESSION_ID]), + ); + + const stop = await screen.findByRole("button", { name: /Stop/ }); + await waitFor( + () => expect((stop as HTMLButtonElement).disabled).toBe(false), + { + timeout: 2000, + }, + ); + fireEvent.click(stop); + + await waitFor(() => expect(cancels).toBe(1)); + await waitFor(() => + expect( + (screen.getByPlaceholderText("Message Zara…") as HTMLTextAreaElement) + .value, + ).toBe("and this"), + ); + expect(screen.queryByTestId("pending-upload-message")).toBeNull(); + expect(recorder.streamBodies.length).toBe(1); + expect(recorder.pendingPosts).toBe(0); + }); + test("gives a follow-up waiting on the reply back when the user starts a new task", async () => { const user = userEvent.setup(); const recorder = newRecorder(); diff --git a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatDrawer.ts b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatDrawer.ts index 89e0fbc27d38..5073997ab8d4 100644 --- a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatDrawer.ts +++ b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatDrawer.ts @@ -203,7 +203,7 @@ export function useExpertChatDrawer({ messages, setMessages, sendMessage, - stop, + stop: stopStream, status, error, isReconnecting, @@ -216,12 +216,13 @@ export function useExpertChatDrawer({ refetchSession: sessionQuery.refetch, copilotModel: undefined, }); - const { pendingSend, hold, release, send } = useExpertChatSend({ + const { pendingSend, hold, release, send, stop } = useExpertChatSend({ sessionId, status, isReconnecting, isFinishProbing, sendMessage, + stop: stopStream, }); const hasAssistantReply = messages.some( diff --git a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatSend.ts b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatSend.ts index fe223729fec2..fcde6a1241cf 100644 --- a/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatSend.ts +++ b/autogpt_platform/frontend/src/app/(platform)/team/components/ExpertChatDrawer/useExpertChatSend.ts @@ -21,6 +21,7 @@ interface Args { isReconnecting: boolean; isFinishProbing: boolean; sendMessage: (message: SendInput) => Promise; + stop: () => Promise; } /** @@ -36,6 +37,7 @@ export function useExpertChatSend({ isReconnecting, isFinishProbing, sendMessage, + stop, }: Args) { const [heldMessage, setHeldMessage] = useState(null); const sessionIdRef = useRef(sessionId); @@ -52,6 +54,9 @@ export function useExpertChatSend({ isSettledRef.current = isSettled; const { waitUntilSettled } = useSettleGate(isSettled); const nextCommit = useNextCommit(); + // Bumped by every Stop. The backend drops the follow-ups it buffered when + // a reply is cancelled, so one held here for the reply drops with them. + const stopCountRef = useRef(0); const placeholder = useCopilotStreamStore((s) => sessionId ? (s.pendingUploadSends[sessionId] ?? null) : null, ); @@ -67,6 +72,11 @@ export function useExpertChatSend({ ); } + function stopReply() { + stopCountRef.current += 1; + return stop(); + } + function latestSendMessage(input: SendInput) { return sendMessageRef.current(input); } @@ -84,7 +94,13 @@ export function useExpertChatSend({ * uploads outlived its own thread goes back to the composer instead. */ function isStillShown(targetSessionId: string, message: OutgoingMessage) { if (sessionIdRef.current === targetSessionId) return true; - returnToComposer(message); + returnToComposer(message, "new-task"); + return false; + } + + function wasNotStopped(stopCount: number, message: OutgoingMessage) { + if (stopCountRef.current === stopCount) return true; + returnToComposer(message, "stopped"); return false; } @@ -96,6 +112,7 @@ export function useExpertChatSend({ * while it reconnects without saying so to its caller. */ function send(targetSessionId: string, message: OutgoingMessage) { + const stopCount = stopCountRef.current; const sent = sendWithAttachments({ sessionId: targetSessionId, message, @@ -104,7 +121,8 @@ export function useExpertChatSend({ ? async () => isStillShown(targetSessionId, message) && (await waitForReplyToEnd()) && - isStillShown(targetSessionId, message) + isStillShown(targetSessionId, message) && + wasNotStopped(stopCount, message) : undefined, }); // The send above put the message on screen in this same tick, as the @@ -119,18 +137,27 @@ export function useExpertChatSend({ hold, release, send, + stop: stopReply, }; } -/** The user started a new task while the message's files uploaded or it - * waited on the reply. */ -function returnToComposer(message: OutgoingMessage) { +const UNSENT_BECAUSE = { + "new-task": "You started a new task before it went out.", + stopped: "You stopped the reply before it went out.", +}; + +/** The user started a new task, or stopped the reply, while the message's + * files uploaded or it waited on the reply. */ +function returnToComposer( + message: OutgoingMessage, + reason: keyof typeof UNSENT_BECAUSE, +) { if (message.text) useCopilotUIStore.getState().restoreDraft(message.text); toast({ title: "Message not sent", description: message.text - ? "You started a new task before it went out. Its text is back in the composer; attach the files again." - : "You started a new task before it went out. Attach the files again to send them.", + ? `${UNSENT_BECAUSE[reason]} Its text is back in the composer; attach the files again.` + : `${UNSENT_BECAUSE[reason]} Attach the files again to send them.`, }); }