Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
extractSendMessageText,
formatNotificationTitle,
getCopilotAuthHeaders,
extractSendMessageAttachments,
getSendSuppressionReason,
hasVisibleAssistantContent,
parseSessionIDs,
Expand Down Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,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;
/** Out of AutoPilot budget with a reset ahead: the text stays editable and
* submitting sets it to send at the reset. Text only. */
atReset?: { onSchedule: (message: string) => Promise<void> };
Expand Down Expand Up @@ -156,6 +159,7 @@ export function ChatInput({
variant = "default",
expertId = null,
expertName = null,
canAttachWhileStreaming = false,
atReset,
}: Props) {
const { isDryRun, setIsDryRun } = useCopilotUIStore();
Expand Down Expand Up @@ -195,7 +199,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;
Expand Down Expand Up @@ -311,6 +316,9 @@ export function ChatInput({
: !sendDisabled && (!!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
Expand Down Expand Up @@ -592,13 +600,16 @@ export function ChatInput({
}}
/>
)}
{isStreaming && canSend && onEnqueue && !hasAttachments && (
{isStreaming && canSend && canQueue && (
<PromptInputButton
aria-label="Queue message"
tooltip="Queue message"
variant="default"
disabled={isEnqueueing}
onClick={() => 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,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(
<ChatInput
onSend={onSend}
onEnqueue={onEnqueue}
isStreaming
canAttachWhileStreaming
/>,
);
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(<ChatInput onSend={onSend} isStreaming />);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,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() {
Expand All @@ -60,6 +65,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;
Expand Down
43 changes: 40 additions & 3 deletions autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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[];
Expand All @@ -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;

Expand All @@ -263,6 +293,7 @@ export type SuppressReason = "reconnecting" | "duplicate" | null;
*/
export function getSendSuppressionReason({
text,
attachments = [],
isReconnectScheduled,
lastSubmittedText,
messages,
Expand All @@ -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;
Expand Down
Loading
Loading