🤐 feat: Allow Promptless Sends When Files Are Attached (#13717)

* ✨ feat: Allow sending file attachments without a text message

When an agent asks the user to upload a document, the user could attach
the file but still had to type a placeholder message ("OK", "Here is
the file") before the send button enabled and the submit guard let the
message through.

Attachments now count as submittable content:

- New isSubmittableMessage(text, fileCount) util: non-whitespace text
  OR at least one attached file.
- ask() in useChatFunctions uses it instead of bailing on empty text,
  so an empty draft with attached files submits.
- SendButton receives the attached file count and enables accordingly.
- ChatForm only marks the text field as required when no files are
  attached, so react-hook-form validation no longer blocks handleSubmit.

Submitting an empty draft with no attachments is still rejected at all
three layers.

Fixes #13646

* Address review: support replayed file-only turns + drop empty vision text

- ask(): count replayed attachments (overrideFiles) in the submittable
  check and skip it entirely for regenerate, so a file-only message can
  be regenerated or saved-and-resubmitted instead of being rejected as
  empty.
- formatVisionMessage(): omit the text content part when the message text
  is empty. Anthropic rejects empty text content blocks with HTTP 400,
  and an empty block adds nothing for other providers; image-only sends
  now format cleanly. Added formatMessages tests for with-text and
  image-only (Anthropic + other) cases.

* Address review: keep attachment-only turns valid for providers, answer mode, and titles

- formatMessage: substitute minimal text when a user turn carries files but no
  inline content, so Anthropic does not reject an empty user message for RAG or
  code-environment attachments.
- assistants chatV1: send the same stand-in for attachment-only Threads
  messages, which reject an empty body. The persisted message keeps empty text.
- ChatForm: attachments no longer make an empty draft submittable in answer
  mode, where submitText consumes the click without answering or sending.
- agents request: seed title generation from attachment filenames when the turn
  has no text, so immediate-mode titles are not invented from an empty string.
- useChatFunctions.regenerate.spec: mock the utils barrel over the real module
  so new exports resolve.

* Cover the agents path for attachment-only turns

AgentClient formats its payload with the SDK's formatMessage, not the local
one, so the earlier guard missed the endpoint the feature actually targets: an
attachment-only turn still reached Anthropic as an empty user message. Apply
the same stand-in after the file-context and quote merges, so a turn that
already gained inline content is untouched.

* Carry filenames on freshly attached files

The fresh-file submission mapping copied only file_id, filepath, type, and
dimensions, so the attachment-only title fallback read an undefined filename
and produced nothing. Include filename, and cover it with a test that submits
an empty draft with one attachment.

* Address review: cover assistants v2, fresh agent attachments, editor, and title fallback

- agents client: the current turn has no files during buildMessages, so read
  the resolved attachments from message_file_map instead. The previous guard
  only ever fired for persisted historical turns.
- assistants chatV2: the default assistants endpoint routes here, so it needs
  the same stand-in body chatV1 got.
- assistants title: fall back to filenames, then the response, and keep the
  default title rather than saving an empty one.
- EditMessage: retained attachments make an empty edit submittable, matching
  the composer, so the overrideFiles replay path is reachable from the UI.

---------

Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
This commit is contained in:
Anubhav Anand 2026-08-15 22:17:31 +05:30 • committed by GitHub
parent e1ac7d2bda
commit a2ad0aa0c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 375 additions and 16 deletions

View file

@ -397,8 +397,14 @@ const ChatForm = memo(function ChatForm({
useQueryParams({ textAreaRef });
/** Attachments stand in for text only on the normal send path. Answer mode
* hands the composer text straight to the paused run, which answers with
* values and cannot consume files, so an empty draft must stay unsubmittable
* there rather than enabling a button whose submit is silently dropped. */
const submittableFileCount = answerMode.active ? 0 : files.size;
const { ref, ...registerProps } = methods.register('text', {
required: true,
required: submittableFileCount === 0,
onChange: useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) =>
methods.setValue('text', e.target.value, { shouldValidate: true }),
@ -699,6 +705,7 @@ const ChatForm = memo(function ChatForm({
<SendButton
ref={submitButtonRef}
control={methods.control}
fileCount={submittableFileCount}
disabled={
filesLoading ||
disableInputs ||

View file

@ -2,12 +2,14 @@ import React, { forwardRef } from 'react';
import { useWatch } from 'react-hook-form';
import { SendIcon, TooltipAnchor } from '@librechat/client';
import type { Control } from 'react-hook-form';
import { cn, isSubmittableMessage } from '~/utils';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
type SendButtonProps = {
disabled: boolean;
control: Control<{ text: string }>;
/** Number of attached files; attachments allow sending without text */
fileCount?: number;
};
const SubmitButton = React.memo(
@ -41,8 +43,8 @@ const SubmitButton = React.memo(
const SendButton = React.memo(
forwardRef((props: SendButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) => {
const data = useWatch({ control: props.control });
const content = data?.text?.trim();
return <SubmitButton ref={ref} disabled={props.disabled || !content} />;
const canSubmit = isSubmittableMessage(data?.text, props.fileCount);
return <SubmitButton ref={ref} disabled={props.disabled || !canSubmit} />;
}),
);

View file

@ -185,7 +185,9 @@ const EditMessage = ({
);
const { ref, ...registerProps } = register('text', {
required: true,
/** Retained attachments make an otherwise empty edit submittable, matching
* the composer; `ask` replays them through `overrideFiles`. */
required: (message.files?.length ?? 0) === 0,
onChange: (e) => {
setValue('text', e.target.value, { shouldDirty: true, shouldValidate: true });
},

View file

@ -57,6 +57,7 @@ jest.mock('~/store', () => ({
useGetEphemeralAgent: () => mockGetEphemeralAgent,
}));
jest.mock('~/utils', () => ({
...jest.requireActual('~/utils'),
logger: {
log: jest.fn(),
dir: jest.fn(),
@ -292,3 +293,52 @@ describe('useChatFunctions regenerate', () => {
expect(messages.at(-1)?.messageId).toBe('assistant-1_');
});
});
describe('useChatFunctions ask attachments', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetQueryData.mockReturnValue({});
});
/** The server titles an attachment-only turn from the submitted filenames
* (getAttachmentTitleText), so the fresh-file mapping must carry them. */
it('carries the filename on freshly attached files', () => {
const setMessages = jest.fn();
const setSubmission = jest.fn();
const setFiles = jest.fn();
const files = new Map([
[
'file-1',
{
file_id: 'file-1',
filepath: '/uploads/file-1',
filename: 'quarterly-report.pdf',
type: 'application/pdf',
},
],
]) as unknown as Parameters<typeof useChatFunctions>[0]['files'];
const { result } = renderHook(() =>
useChatFunctions({
isSubmitting: false,
latestMessage: null,
conversation: conversation(Constants.NEW_CONVO as string),
getMessages: () => [],
setMessages,
setSubmission,
files,
setFiles,
}),
);
act(() => {
result.current.ask({ text: '' });
});
const submission = setSubmission.mock.calls.at(-1)?.[0] as TSubmission;
expect(submission.userMessage.files?.[0]).toMatchObject({
file_id: 'file-1',
filename: 'quarterly-report.pdf',
});
});
});

View file

@ -30,6 +30,7 @@ import {
logger,
requestChatFocus,
hasStreamStartFailed,
isSubmittableMessage,
createDualMessageContent,
getRouteChatProjectId,
} from '~/utils';
@ -292,7 +293,18 @@ export default function useChatFunctions({
} = {},
) => {
text = text.trim();
if (!!isSubmitting || text === '') {
/**
* Attached files make an otherwise empty draft submittable, e.g. replying
* to an agent that asked for a document upload. Replayed turns (regenerate,
* or save-and-submit carrying `overrideFiles`) reuse stored attachments that
* aren't in the compose `files` map, so count those too and never re-block a
* regenerate of an already-validated file-only turn.
*/
const replayFileCount = overrideFiles?.length ?? 0;
if (
!!isSubmitting ||
(!isRegenerate && !isSubmittableMessage(text, (files?.size ?? 0) + replayFileCount))
) {
return false;
}
@ -548,6 +560,7 @@ export default function useChatFunctions({
currentMsg.files = Array.from(files.values()).map((file) => ({
file_id: file.file_id,
filepath: file.filepath,
filename: file.filename,
type: file.type ?? '', // Ensure type is not undefined
height: file.height,
width: file.width,

View file

@ -11,6 +11,7 @@ import {
getHeaderPrefixForScreenReader,
areMessageFieldsEqual,
areMessageRowPropsEqual,
isSubmittableMessage,
} from '../messages';
const translations: Record<string, string> = {
@ -344,3 +345,27 @@ describe('areMessageRowPropsEqual', () => {
).toBe(false);
});
});
describe('isSubmittableMessage', () => {
it('accepts non-whitespace text without files', () => {
expect(isSubmittableMessage('Hello')).toBe(true);
expect(isSubmittableMessage(' Hello ', 0)).toBe(true);
});
it('rejects an empty draft with no files', () => {
expect(isSubmittableMessage('')).toBe(false);
expect(isSubmittableMessage(' ')).toBe(false);
expect(isSubmittableMessage(undefined)).toBe(false);
expect(isSubmittableMessage(null)).toBe(false);
});
it('accepts an empty draft when files are attached', () => {
expect(isSubmittableMessage('', 1)).toBe(true);
expect(isSubmittableMessage(' ', 2)).toBe(true);
expect(isSubmittableMessage(undefined, 1)).toBe(true);
});
it('accepts text alongside files', () => {
expect(isSubmittableMessage('Translate this', 1)).toBe(true);
});
});

View file

@ -191,6 +191,14 @@ export const getAllContentText = (message?: TMessage | null): string => {
return '';
};
/**
* Whether a draft message has enough content to submit: non-whitespace
* text, or at least one attached file. Lets users send a file without
* having to type a placeholder message alongside it.
*/
export const isSubmittableMessage = (text?: string | null, fileCount = 0): boolean =>
(text ?? '').trim() !== '' || fileCount > 0;
export const hasStreamStartFailed = (message?: Pick<TMessage, 'metadata'> | null): boolean =>
message?.metadata?.[STREAM_START_FAILED_METADATA_KEY] === true;