fix(app): clear composer immediately on submit
This commit is contained in:
@@ -45,6 +45,7 @@ import { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation
|
|||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
|
import { submitAgentInput } from "@/components/agent-input-submit";
|
||||||
|
|
||||||
type QueuedMessage = {
|
type QueuedMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -342,43 +343,31 @@ export function AgentInputArea({
|
|||||||
imageAttachments?: ImageAttachment[],
|
imageAttachments?: ImageAttachment[],
|
||||||
forceSend?: boolean,
|
forceSend?: boolean,
|
||||||
) {
|
) {
|
||||||
const trimmedMessage = message.trim();
|
await submitAgentInput({
|
||||||
if (!trimmedMessage && !imageAttachments?.length) return;
|
message,
|
||||||
// When the parent controls submission (e.g. draft agent creation), let it
|
imageAttachments,
|
||||||
// decide what to do even if the socket is currently disconnected (so we
|
forceSend,
|
||||||
// don't no-op and lose deterministic error handling in the UI/tests).
|
isAgentRunning: agent?.status === "running",
|
||||||
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
|
// Parent-managed submits are still valid submit paths even when the
|
||||||
|
// transport is disconnected, because the parent decides the failure mode.
|
||||||
if (agent?.status === "running" && !forceSend) {
|
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
|
||||||
queueMessage(trimmedMessage, imageAttachments);
|
queueMessage: ({ message, imageAttachments }) => {
|
||||||
return;
|
queueMessage(message, imageAttachments);
|
||||||
}
|
},
|
||||||
|
submitMessage: async ({ message, imageAttachments }) => {
|
||||||
// Clear input optimistically before awaiting server ack.
|
await submitMessage(message, imageAttachments);
|
||||||
// Save values so we can restore on error.
|
},
|
||||||
const savedImages = imageAttachments;
|
clearDraft,
|
||||||
if (!onSubmitMessageRef.current) {
|
setUserInput,
|
||||||
setUserInput("");
|
setSelectedImages: (images) => {
|
||||||
setSelectedImages([]);
|
setSelectedImages(images);
|
||||||
}
|
},
|
||||||
setSendError(null);
|
setSendError,
|
||||||
setIsProcessing(true);
|
setIsProcessing,
|
||||||
|
onSubmitError: (error) => {
|
||||||
try {
|
console.error("[AgentInput] Failed to send message:", error);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(payload: MessagePayload) {
|
function handleSubmit(payload: MessagePayload) {
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { submitAgentInput } from "./agent-input-submit";
|
||||||
|
|
||||||
|
function createDeferredPromise<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((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<void>();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed";
|
||||||
|
|
||||||
|
export interface AgentInputSubmitActionInput<TImage> {
|
||||||
|
message: string;
|
||||||
|
imageAttachments?: TImage[];
|
||||||
|
forceSend?: boolean;
|
||||||
|
isAgentRunning: boolean;
|
||||||
|
canSubmit: boolean;
|
||||||
|
queueMessage: (input: { message: string; imageAttachments?: TImage[] }) => void;
|
||||||
|
submitMessage: (input: { message: string; imageAttachments?: TImage[] }) => Promise<void>;
|
||||||
|
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<TImage>(
|
||||||
|
input: AgentInputSubmitActionInput<TImage>,
|
||||||
|
): Promise<AgentInputSubmitResult> {
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user