diff --git a/packages/elements/__tests__/prompt-input.test.tsx b/packages/elements/__tests__/prompt-input.test.tsx
index d961a3ba..7dc1e230 100644
--- a/packages/elements/__tests__/prompt-input.test.tsx
+++ b/packages/elements/__tests__/prompt-input.test.tsx
@@ -3294,3 +3294,68 @@ describe("integration tests", () => {
).toBeInTheDocument();
});
});
+
+describe("promptInput hosted file uploads", () => {
+ it("submits the hosted URL returned by onFileAdd", async () => {
+ setupPromptInputTests();
+ const onSubmit = vi.fn();
+ const onFileAdd = vi.fn().mockResolvedValue("https://cdn.example.com/image.png");
+ const user = userEvent.setup();
+
+ render(
+
+
+
+
+
+
+ );
+
+ const file = new File(["image"], "image.png", { type: "image/png" });
+ await user.upload(screen.getByLabelText("Upload files"), file);
+ await vi.waitFor(() => expect(onFileAdd).toHaveBeenCalledWith(file));
+ await user.click(screen.getByRole("button", { name: SUBMIT_REGEX }));
+
+ await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
+ expect(onSubmit.mock.calls[0][0].files[0]?.url).toBe(
+ "https://cdn.example.com/image.png"
+ );
+ });
+
+ it("disables submit while a file upload is pending", async () => {
+ setupPromptInputTests();
+ const onSubmit = vi.fn();
+ let resolveUpload!: (url: string) => void;
+ const onFileAdd = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveUpload = resolve;
+ })
+ );
+ const user = userEvent.setup();
+
+ render(
+
+
+
+
+
+
+ );
+
+ const file = new File(["image"], "image.png", { type: "image/png" });
+ await user.upload(screen.getByLabelText("Upload files"), file);
+ await vi.waitFor(() => expect(onFileAdd).toHaveBeenCalledOnce());
+
+ const submitButton = screen.getByRole("button", { name: SUBMIT_REGEX });
+ expect(submitButton).toBeDisabled();
+ await user.click(submitButton);
+ expect(onSubmit).not.toHaveBeenCalled();
+
+ await act(async () => {
+ resolveUpload("https://cdn.example.com/image.png");
+ });
+ await vi.waitFor(() => expect(submitButton).toBeEnabled());
+ });
+});
+
diff --git a/packages/elements/src/prompt-input.tsx b/packages/elements/src/prompt-input.tsx
index 412c846d..4854f19c 100644
--- a/packages/elements/src/prompt-input.tsx
+++ b/packages/elements/src/prompt-input.tsx
@@ -185,6 +185,8 @@ export interface AttachmentsContext {
clear: () => void;
openFileDialog: () => void;
fileInputRef: RefObject;
+ addUploaded: (files: FileUIPart[]) => void;
+ isUploading?: boolean;
}
export interface TextInputContext {
@@ -261,6 +263,13 @@ export const PromptInputProvider = ({
// oxlint-disable-next-line eslint(no-empty-function)
const openRef = useRef<() => void>(() => {});
+ const addUploaded = useCallback((files: FileUIPart[]) => {
+ setAttachmentFiles((prev) => [
+ ...prev,
+ ...files.map((file) => ({ ...file, id: nanoid() })),
+ ]);
+ }, []);
+
const add = useCallback((files: File[] | FileList) => {
const incoming = [...files];
if (incoming.length === 0) {
@@ -326,13 +335,14 @@ export const PromptInputProvider = ({
const attachments = useMemo(
() => ({
add,
+ addUploaded,
clear,
fileInputRef,
files: attachmentFiles,
openFileDialog,
remove,
}),
- [attachmentFiles, add, remove, clear, openFileDialog]
+ [attachmentFiles, add, addUploaded, remove, clear, openFileDialog]
);
const __registerFileInput = useCallback(
@@ -501,6 +511,8 @@ export type PromptInputProps = Omit<
maxFiles?: number;
// bytes
maxFileSize?: number;
+ onFileAdd?: (file: File) => Promise;
+ onFileRemove?: (fileUrl: string) => Promise;
onError?: (err: {
code: "max_files" | "max_file_size" | "accept";
message: string;
@@ -520,6 +532,8 @@ export const PromptInput = ({
maxFiles,
maxFileSize,
onError,
+ onFileAdd,
+ onFileRemove,
onSubmit,
children,
...props
@@ -534,6 +548,7 @@ export const PromptInput = ({
// ----- Local attachments (only used when no provider)
const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
+ const [isUploading, setIsUploading] = useState(false);
const files = usingProvider ? controller.attachments.files : items;
// ----- Local referenced sources (always local to PromptInput)
@@ -597,33 +612,53 @@ export const PromptInput = ({
return;
}
- setItems((prev) => {
- const capacity =
- typeof maxFiles === "number"
- ? Math.max(0, maxFiles - prev.length)
- : undefined;
- const capped =
- typeof capacity === "number" ? sized.slice(0, capacity) : sized;
- if (typeof capacity === "number" && sized.length > capacity) {
- onError?.({
- code: "max_files",
- message: "Too many files. Some were not added.",
- });
- }
- const next: (FileUIPart & { id: string })[] = [];
- for (const file of capped) {
- next.push({
- filename: file.name,
- id: nanoid(),
- mediaType: file.type,
- type: "file",
- url: URL.createObjectURL(file),
- });
- }
- return [...prev, ...next];
- });
+ const capacity =
+ typeof maxFiles === "number"
+ ? Math.max(0, maxFiles - files.length)
+ : undefined;
+ const capped =
+ typeof capacity === "number" ? sized.slice(0, capacity) : sized;
+ if (typeof capacity === "number" && sized.length > capacity) {
+ onError?.({
+ code: "max_files",
+ message: "Too many files. Some were not added.",
+ });
+ }
+ if (!capped.length) return;
+ setIsUploading(!!onFileAdd);
+ Promise.all(
+ capped.map(async (file) => ({
+ filename: file.name,
+ mediaType: file.type,
+ type: "file" as const,
+ url: onFileAdd ? await onFileAdd(file) : URL.createObjectURL(file),
+ }))
+ )
+ .then((uploaded) => {
+ if (usingProvider) {
+ controller?.attachments.addUploaded(uploaded);
+ } else {
+ setItems((prev) => [
+ ...prev,
+ ...uploaded.map((file) => ({ ...file, id: nanoid() })),
+ ]);
+ }
+ })
+ .catch(() => {
+ // Keep existing attachments if an upload fails.
+ })
+ .finally(() => setIsUploading(false));
},
- [matchesAccept, maxFiles, maxFileSize, onError]
+ [
+ matchesAccept,
+ maxFiles,
+ maxFileSize,
+ onError,
+ files.length,
+ onFileAdd,
+ usingProvider,
+ controller,
+ ]
);
const removeLocal = useCallback(
@@ -676,10 +711,37 @@ export const PromptInput = ({
}
if (capped.length > 0) {
- controller?.attachments.add(capped);
+ if (!onFileAdd) {
+ controller?.attachments.add(capped);
+ return;
+ }
+ setIsUploading(true);
+ Promise.all(
+ capped.map(async (file) => ({
+ filename: file.name,
+ mediaType: file.type,
+ type: "file" as const,
+ url: await onFileAdd(file),
+ }))
+ )
+ .then((uploaded) => {
+ controller?.attachments.addUploaded(uploaded);
+ })
+ .catch(() => {
+ // Keep existing attachments if an upload fails.
+ })
+ .finally(() => setIsUploading(false));
}
},
- [matchesAccept, maxFileSize, maxFiles, onError, files.length, controller]
+ [
+ matchesAccept,
+ maxFileSize,
+ maxFiles,
+ onError,
+ files.length,
+ controller,
+ onFileAdd,
+ ]
);
const clearAttachments = useCallback(
@@ -703,7 +765,16 @@ export const PromptInput = ({
);
const add = usingProvider ? addWithProviderValidation : addLocal;
- const remove = usingProvider ? controller.attachments.remove : removeLocal;
+ const remove = useCallback((id: string) => {
+ const found = files.find((file) => file.id === id);
+ if (found?.url && !found.url.startsWith("blob:") && onFileRemove) {
+ void onFileRemove(found.url).catch(() => {
+ // UI removal should not be blocked by remote cleanup failures.
+ });
+ }
+ if (usingProvider) controller.attachments.remove(id);
+ else removeLocal(id);
+ }, [files, onFileRemove, usingProvider, controller, removeLocal]);
const openFileDialog = usingProvider
? controller.attachments.openFileDialog
: openFileDialogLocal;
@@ -817,10 +888,15 @@ export const PromptInput = ({
clear: clearAttachments,
fileInputRef: inputRef,
files: files.map((item) => ({ ...item, id: item.id })),
+ isUploading,
+ addUploaded: (uploaded) => {
+ if (usingProvider) controller?.attachments.addUploaded(uploaded);
+ else setItems((prev) => [...prev, ...uploaded.map((file) => ({ ...file, id: nanoid() }))]);
+ },
openFileDialog,
remove,
}),
- [files, add, remove, clearAttachments, openFileDialog]
+ [files, add, remove, clearAttachments, openFileDialog, isUploading, usingProvider, controller]
);
const refsCtx = useMemo(
@@ -844,6 +920,7 @@ export const PromptInput = ({
const handleSubmit: FormEventHandler = useCallback(
async (event) => {
event.preventDefault();
+ if (isUploading) return;
const form = event.currentTarget;
const text = usingProvider
@@ -899,7 +976,7 @@ export const PromptInput = ({
// Don't clear on error - user may want to retry
}
},
- [usingProvider, controller, files, onSubmit, clear]
+ [usingProvider, controller, files, onSubmit, clear, isUploading]
);
// Render with or without local provider
@@ -1223,6 +1300,7 @@ export const PromptInputSubmit = ({
children,
...props
}: PromptInputSubmitProps) => {
+ const attachments = usePromptInputAttachments();
const isGenerating = status === "submitted" || status === "streaming";
let Icon = ;
@@ -1251,6 +1329,7 @@ export const PromptInputSubmit = ({