diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 750bb68eb..a44f30bb3 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -45,6 +45,7 @@ import { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; +import { submitAgentInput } from "@/components/agent-input-submit"; type QueuedMessage = { id: string; @@ -342,43 +343,31 @@ export function AgentInputArea({ imageAttachments?: ImageAttachment[], forceSend?: boolean, ) { - const trimmedMessage = message.trim(); - if (!trimmedMessage && !imageAttachments?.length) return; - // When the parent controls submission (e.g. draft agent creation), let it - // decide what to do even if the socket is currently disconnected (so we - // don't no-op and lose deterministic error handling in the UI/tests). - if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return; - - if (agent?.status === "running" && !forceSend) { - queueMessage(trimmedMessage, imageAttachments); - return; - } - - // Clear input optimistically before awaiting server ack. - // Save values so we can restore on error. - const savedImages = imageAttachments; - if (!onSubmitMessageRef.current) { - setUserInput(""); - setSelectedImages([]); - } - setSendError(null); - setIsProcessing(true); - - try { - await submitMessage(trimmedMessage, imageAttachments); - clearDraft("sent"); - } catch (error) { - console.error("[AgentInput] Failed to send message:", error); - // Restore input so the user never loses their message - if (!onSubmitMessageRef.current) { - setUserInput(trimmedMessage); - } - if (savedImages) { - setSelectedImages(savedImages); - } - setSendError(error instanceof Error ? error.message : "Failed to send message"); - setIsProcessing(false); - } + await submitAgentInput({ + message, + imageAttachments, + forceSend, + isAgentRunning: agent?.status === "running", + // Parent-managed submits are still valid submit paths even when the + // transport is disconnected, because the parent decides the failure mode. + canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current), + queueMessage: ({ message, imageAttachments }) => { + queueMessage(message, imageAttachments); + }, + submitMessage: async ({ message, imageAttachments }) => { + await submitMessage(message, imageAttachments); + }, + clearDraft, + setUserInput, + setSelectedImages: (images) => { + setSelectedImages(images); + }, + setSendError, + setIsProcessing, + onSubmitError: (error) => { + console.error("[AgentInput] Failed to send message:", error); + }, + }); } function handleSubmit(payload: MessagePayload) { diff --git a/packages/app/src/components/agent-input-submit.test.ts b/packages/app/src/components/agent-input-submit.test.ts new file mode 100644 index 000000000..7008f725a --- /dev/null +++ b/packages/app/src/components/agent-input-submit.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from "vitest"; +import { submitAgentInput } from "./agent-input-submit"; + +function createDeferredPromise() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { + promise, + resolve, + reject, + }; +} + +describe("submitAgentInput", () => { + it("clears the composer before an in-flight submit resolves", async () => { + const deferred = createDeferredPromise(); + const queueMessage = vi.fn(); + const submitMessage = vi.fn(async () => { + await deferred.promise; + }); + const clearDraft = vi.fn(); + const setUserInput = vi.fn(); + const setSelectedImages = vi.fn(); + const setSendError = vi.fn(); + const setIsProcessing = vi.fn(); + + const submitPromise = submitAgentInput({ + message: " hello world ", + isAgentRunning: false, + canSubmit: true, + queueMessage, + submitMessage, + clearDraft, + setUserInput, + setSelectedImages, + setSendError, + setIsProcessing, + }); + + expect(queueMessage).not.toHaveBeenCalled(); + expect(submitMessage).toHaveBeenCalledWith({ + message: "hello world", + imageAttachments: undefined, + }); + expect(setUserInput).toHaveBeenCalledWith(""); + expect(setSelectedImages).toHaveBeenCalledWith([]); + expect(setSendError).toHaveBeenCalledWith(null); + expect(setIsProcessing).toHaveBeenCalledWith(true); + expect(clearDraft).not.toHaveBeenCalled(); + + deferred.resolve(); + + await expect(submitPromise).resolves.toBe("submitted"); + expect(clearDraft).toHaveBeenCalledWith("sent"); + }); + + it("queues while the agent is running and clears the composer immediately", async () => { + const queueMessage = vi.fn(); + const submitMessage = vi.fn(); + const clearDraft = vi.fn(); + const setUserInput = vi.fn(); + const setSelectedImages = vi.fn(); + const setSendError = vi.fn(); + const setIsProcessing = vi.fn(); + + await expect( + submitAgentInput({ + message: " queued message ", + imageAttachments: [{ id: "img-1" }], + isAgentRunning: true, + canSubmit: true, + queueMessage, + submitMessage, + clearDraft, + setUserInput, + setSelectedImages, + setSendError, + setIsProcessing, + }), + ).resolves.toBe("queued"); + + expect(queueMessage).toHaveBeenCalledWith({ + message: "queued message", + imageAttachments: [{ id: "img-1" }], + }); + expect(submitMessage).not.toHaveBeenCalled(); + expect(setUserInput).toHaveBeenCalledWith(""); + expect(setSelectedImages).toHaveBeenCalledWith([]); + expect(setSendError).not.toHaveBeenCalled(); + expect(setIsProcessing).not.toHaveBeenCalled(); + expect(clearDraft).not.toHaveBeenCalled(); + }); + + it("restores the composer when submit fails", async () => { + const submitError = new Error("No host selected"); + const queueMessage = vi.fn(); + const submitMessage = vi.fn(async () => { + throw submitError; + }); + const clearDraft = vi.fn(); + const setUserInput = vi.fn(); + const setSelectedImages = vi.fn(); + const setSendError = vi.fn(); + const setIsProcessing = vi.fn(); + const onSubmitError = vi.fn(); + const imageAttachments = [{ id: "img-1" }]; + + await expect( + submitAgentInput({ + message: " hello world ", + imageAttachments, + isAgentRunning: false, + canSubmit: true, + queueMessage, + submitMessage, + clearDraft, + setUserInput, + setSelectedImages, + setSendError, + setIsProcessing, + onSubmitError, + }), + ).resolves.toBe("failed"); + + expect(onSubmitError).toHaveBeenCalledWith(submitError); + expect(setUserInput).toHaveBeenNthCalledWith(1, ""); + expect(setUserInput).toHaveBeenNthCalledWith(2, "hello world"); + expect(setSelectedImages).toHaveBeenNthCalledWith(1, []); + expect(setSelectedImages).toHaveBeenNthCalledWith(2, imageAttachments); + expect(setSendError).toHaveBeenNthCalledWith(1, null); + expect(setSendError).toHaveBeenNthCalledWith(2, "No host selected"); + expect(setIsProcessing).toHaveBeenNthCalledWith(1, true); + expect(setIsProcessing).toHaveBeenNthCalledWith(2, false); + expect(clearDraft).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/components/agent-input-submit.ts b/packages/app/src/components/agent-input-submit.ts new file mode 100644 index 000000000..deabff19b --- /dev/null +++ b/packages/app/src/components/agent-input-submit.ts @@ -0,0 +1,58 @@ +export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed"; + +export interface AgentInputSubmitActionInput { + message: string; + imageAttachments?: TImage[]; + forceSend?: boolean; + isAgentRunning: boolean; + canSubmit: boolean; + queueMessage: (input: { message: string; imageAttachments?: TImage[] }) => void; + submitMessage: (input: { message: string; imageAttachments?: TImage[] }) => Promise; + clearDraft: (lifecycle: "sent" | "abandoned") => void; + setUserInput: (text: string) => void; + setSelectedImages: (images: TImage[]) => void; + setSendError: (message: string | null) => void; + setIsProcessing: (isProcessing: boolean) => void; + onSubmitError?: (error: unknown) => void; +} + +export async function submitAgentInput( + input: AgentInputSubmitActionInput, +): Promise { + const trimmedMessage = input.message.trim(); + const imageAttachments = input.imageAttachments; + + if (!trimmedMessage && !imageAttachments?.length) { + return "noop"; + } + + if (!input.canSubmit) { + return "noop"; + } + + if (input.isAgentRunning && !input.forceSend) { + input.queueMessage({ message: trimmedMessage, imageAttachments }); + input.setUserInput(""); + input.setSelectedImages([]); + return "queued"; + } + + // Clear immediately so optimistic stream updates and composer state stay in sync. + input.setUserInput(""); + input.setSelectedImages([]); + input.setSendError(null); + input.setIsProcessing(true); + + try { + await input.submitMessage({ message: trimmedMessage, imageAttachments }); + input.clearDraft("sent"); + return "submitted"; + } catch (error) { + input.onSubmitError?.(error); + input.setUserInput(trimmedMessage); + input.setSelectedImages(imageAttachments ?? []); + input.setSendError(error instanceof Error ? error.message : "Failed to send message"); + input.setIsProcessing(false); + return "failed"; + } +}