Skip to content
Open
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
65 changes: 65 additions & 0 deletions packages/elements/__tests__/prompt-input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<PromptInput onSubmit={onSubmit} onFileAdd={onFileAdd}>
<PromptInputBody>
<PromptInputTextarea />
<PromptInputSubmit />
</PromptInputBody>
</PromptInput>
);

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<string>((resolve) => {
resolveUpload = resolve;
})
);
const user = userEvent.setup();

render(
<PromptInput onSubmit={onSubmit} onFileAdd={onFileAdd}>
<PromptInputBody>
<PromptInputTextarea />
<PromptInputSubmit />
</PromptInputBody>
</PromptInput>
);

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());
});
});

143 changes: 111 additions & 32 deletions packages/elements/src/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ export interface AttachmentsContext {
clear: () => void;
openFileDialog: () => void;
fileInputRef: RefObject<HTMLInputElement | null>;
addUploaded: (files: FileUIPart[]) => void;
isUploading?: boolean;
}

export interface TextInputContext {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -326,13 +335,14 @@ export const PromptInputProvider = ({
const attachments = useMemo<AttachmentsContext>(
() => ({
add,
addUploaded,
clear,
fileInputRef,
files: attachmentFiles,
openFileDialog,
remove,
}),
[attachmentFiles, add, remove, clear, openFileDialog]
[attachmentFiles, add, addUploaded, remove, clear, openFileDialog]
);

const __registerFileInput = useCallback(
Expand Down Expand Up @@ -501,6 +511,8 @@ export type PromptInputProps = Omit<
maxFiles?: number;
// bytes
maxFileSize?: number;
onFileAdd?: (file: File) => Promise<string>;
onFileRemove?: (fileUrl: string) => Promise<void>;
onError?: (err: {
code: "max_files" | "max_file_size" | "accept";
message: string;
Expand All @@ -520,6 +532,8 @@ export const PromptInput = ({
maxFiles,
maxFileSize,
onError,
onFileAdd,
onFileRemove,
onSubmit,
children,
...props
Expand All @@ -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)
Expand Down Expand Up @@ -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 =
Comment on lines +615 to +619

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With maxFiles={1}, selecting a second file while the first upload is pending uploads and appends both files, exceeding the limit in both local and provider modes.

Fix on Vercel

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.
Comment on lines +629 to +648

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When one file in a multi-file hosted upload rejects, Promise.all discards every successful attachment in that batch without reporting an error or making those remote files available for removal.

Fix on Vercel

})
.finally(() => setIsUploading(false));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When file selections overlap, either batch settling clears isUploading while the other is pending, allowing submission without the pending attachment and letting it appear in the next message.

Fix on Vercel

},
[matchesAccept, maxFiles, maxFileSize, onError]
[
matchesAccept,
maxFiles,
maxFileSize,
onError,
files.length,
onFileAdd,
usingProvider,
controller,
]
);

const removeLocal = useCallback(
Expand Down Expand Up @@ -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(
Expand All @@ -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;
Expand Down Expand Up @@ -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<ReferencedSourcesContext>(
Expand All @@ -844,6 +920,7 @@ export const PromptInput = ({
const handleSubmit: FormEventHandler<HTMLFormElement> = useCallback(
async (event) => {
event.preventDefault();
if (isUploading) return;

const form = event.currentTarget;
const text = usingProvider
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1223,6 +1300,7 @@ export const PromptInputSubmit = ({
children,
...props
}: PromptInputSubmitProps) => {
const attachments = usePromptInputAttachments();
const isGenerating = status === "submitted" || status === "streaming";

let Icon = <CornerDownLeftIcon className="size-4" />;
Expand Down Expand Up @@ -1251,6 +1329,7 @@ export const PromptInputSubmit = ({
<InputGroupButton
aria-label={isGenerating ? "Stop" : "Submit"}
className={cn(className)}
disabled={attachments.isUploading || props.disabled}
onClick={handleClick}
size={size}
type={isGenerating && onStop ? "button" : "submit"}
Expand Down