Update React Editor integration and clean up template

This commit is contained in:
Rami Bitar
2026-08-15 13:47:33 -04:00
parent 0a7ea0bf6b
commit 0fb6cef226
82 changed files with 113 additions and 913 deletions
+6 -66
View File
@@ -74,15 +74,10 @@ import {
useState,
} from "react";
// ============================================================================
// Helpers
// ============================================================================
const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => {
try {
const response = await fetch(url);
const blob = await response.blob();
// FileReader uses callback-based API, wrapping in Promise is necessary
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
return new Promise((resolve) => {
const reader = new FileReader();
@@ -118,7 +113,6 @@ const captureScreenshot = async (): Promise<File | null> => {
video.srcObject = stream;
// Video element uses callback-based API, wrapping in Promise is necessary
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
await new Promise<void>((resolve, reject) => {
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
@@ -144,7 +138,6 @@ const captureScreenshot = async (): Promise<File | null> => {
}
context.drawImage(video, 0, 0, width, height);
// canvas.toBlob uses callback-based API, wrapping in Promise is necessary
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, "image/png");
@@ -174,10 +167,6 @@ const captureScreenshot = async (): Promise<File | null> => {
}
};
// ============================================================================
// Provider Context & Types
// ============================================================================
export interface AttachmentsContext {
files: (FileUIPart & { id: string })[];
add: (files: File[] | FileList) => void;
@@ -196,7 +185,6 @@ export interface TextInputContext {
export interface PromptInputControllerProps {
textInput: TextInputContext;
attachments: AttachmentsContext;
/** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
__registerFileInput: (
ref: RefObject<HTMLInputElement | null>,
open: () => void
@@ -220,7 +208,6 @@ export const usePromptInputController = () => {
return ctx;
};
// Optional variants (do NOT throw). Useful for dual-mode components.
const useOptionalPromptInputController = () =>
useContext(PromptInputController);
@@ -241,19 +228,14 @@ export type PromptInputProviderProps = PropsWithChildren<{
initialInput?: string;
}>;
/**
* Optional global provider that lifts PromptInput state outside of PromptInput.
* If you don't use it, PromptInput stays fully self-managed.
*/
/** Lifts prompt state when shared control is needed. */
export const PromptInputProvider = ({
initialInput: initialTextInput = "",
children,
}: PromptInputProviderProps) => {
// ----- textInput state
const [textInput, setTextInput] = useState(initialTextInput);
const clearInput = useCallback(() => setTextInput(""), []);
// ----- attachments state (global when wrapped)
const [attachmentFiles, setAttachmentFiles] = useState<
(FileUIPart & { id: string })[]
>([]);
@@ -300,14 +282,13 @@ export const PromptInputProvider = ({
});
}, []);
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
// Keep the latest files available to the unmount cleanup.
const attachmentsRef = useRef(attachmentFiles);
useEffect(() => {
attachmentsRef.current = attachmentFiles;
}, [attachmentFiles]);
// Cleanup blob URLs on unmount to prevent memory leaks
useEffect(
() => () => {
for (const f of attachmentsRef.current) {
@@ -365,14 +346,9 @@ export const PromptInputProvider = ({
);
};
// ============================================================================
// Component Context & Hooks
// ============================================================================
const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
export const usePromptInputAttachments = () => {
// Prefer local context (inside PromptInput) as it has validation, fall back to provider
const provider = useOptionalProviderAttachments();
const local = useContext(LocalAttachmentsContext);
const context = local ?? provider;
@@ -384,10 +360,6 @@ export const usePromptInputAttachments = () => {
return context;
};
// ============================================================================
// Referenced Sources (Local to PromptInput)
// ============================================================================
export interface ReferencedSourcesContext {
sources: (SourceDocumentUIPart & { id: string })[];
add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void;
@@ -490,16 +462,11 @@ export type PromptInputProps = Omit<
HTMLAttributes<HTMLFormElement>,
"onSubmit" | "onError"
> & {
// e.g., "image/*" or leave undefined for any
accept?: string;
multiple?: boolean;
// When true, accepts drops anywhere on document. Default false (opt-in).
globalDrop?: boolean;
// Render a hidden input with given name and keep it in sync for native form posts. Default false.
syncHiddenInput?: boolean;
// Minimal constraints
maxFiles?: number;
// bytes
maxFileSize?: number;
onError?: (err: {
code: "max_files" | "max_file_size" | "accept";
@@ -524,24 +491,19 @@ export const PromptInput = ({
children,
...props
}: PromptInputProps) => {
// Try to use a provider controller if present
const controller = useOptionalPromptInputController();
const usingProvider = !!controller;
// Refs
const inputRef = useRef<HTMLInputElement | null>(null);
const formRef = useRef<HTMLFormElement | null>(null);
// ----- Local attachments (only used when no provider)
const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
const files = usingProvider ? controller.attachments.files : items;
// ----- Local referenced sources (always local to PromptInput)
const [referencedSources, setReferencedSources] = useState<
(SourceDocumentUIPart & { id: string })[]
>([]);
// Keep a ref to files for cleanup on unmount (avoids stale closure)
const filesRef = useRef(files);
useEffect(() => {
@@ -565,7 +527,6 @@ export const PromptInput = ({
return patterns.some((pattern) => {
if (pattern.endsWith("/*")) {
// e.g: image/* -> image/
const prefix = pattern.slice(0, -1);
return f.type.startsWith(prefix);
}
@@ -638,7 +599,6 @@ export const PromptInput = ({
[]
);
// Wrapper that validates files before calling provider's add
const addWithProviderValidation = useCallback(
(fileList: File[] | FileList) => {
const incoming = [...fileList];
@@ -713,7 +673,6 @@ export const PromptInput = ({
clearReferencedSources();
}, [clearAttachments, clearReferencedSources]);
// Let provider know about our hidden file input so external menus can call openFileDialog()
useEffect(() => {
if (!usingProvider) {
return;
@@ -721,22 +680,19 @@ export const PromptInput = ({
controller.__registerFileInput(inputRef, () => inputRef.current?.click());
}, [usingProvider, controller]);
// Note: File input cannot be programmatically set for security reasons
// The syncHiddenInput prop is no longer functional
// Browsers forbid programmatically populating file inputs.
useEffect(() => {
if (syncHiddenInput && inputRef.current && files.length === 0) {
inputRef.current.value = "";
}
}, [files, syncHiddenInput]);
// Attach drop handlers on nearest form and document (opt-in)
useEffect(() => {
const form = formRef.current;
if (!form) {
return;
}
if (globalDrop) {
// when global drop is on, let the document-level handler own drops
return;
}
@@ -805,7 +761,6 @@ export const PromptInput = ({
if (event.currentTarget.files) {
add(event.currentTarget.files);
}
// Reset input value to allow selecting files that were previously removed
event.currentTarget.value = "";
},
[add]
@@ -853,19 +808,16 @@ export const PromptInput = ({
return (formData.get("message") as string) || "";
})();
// Reset form immediately after capturing text to avoid race condition
// where user input during async blob conversion would be lost
// Reset before async conversion so new input is not cleared afterward.
if (!usingProvider) {
form.reset();
}
try {
// Convert blob URLs to data URLs asynchronously
const convertedFiles: FileUIPart[] = await Promise.all(
files.map(async ({ id: _id, ...item }) => {
if (item.url?.startsWith("blob:")) {
const dataUrl = await convertBlobUrlToDataUrl(item.url);
// If conversion failed, keep the original blob URL
return {
...item,
url: dataUrl ?? item.url,
@@ -877,7 +829,6 @@ export const PromptInput = ({
const result = onSubmit({ files: convertedFiles, text }, event);
// Handle both sync and async onSubmit
if (result instanceof Promise) {
try {
await result;
@@ -886,23 +837,21 @@ export const PromptInput = ({
controller.textInput.clear();
}
} catch {
// Don't clear on error - user may want to retry
// Preserve failed submissions for retry.
}
} else {
// Sync function completed without throwing, clear inputs
clear();
if (usingProvider) {
controller.textInput.clear();
}
}
} catch {
// Don't clear on error - user may want to retry
// Preserve failed submissions for retry.
}
},
[usingProvider, controller, files, onSubmit, clear]
);
// Render with or without local provider
const inner = (
<>
<input
@@ -932,7 +881,6 @@ export const PromptInput = ({
</LocalReferencedSourcesContext.Provider>
);
// Always provide LocalAttachmentsContext so children get validated add function
return (
<LocalAttachmentsContext.Provider value={attachmentsCtx}>
{withReferencedSources}
@@ -966,10 +914,8 @@ export const PromptInputTextarea = ({
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = useCallback(
(e) => {
// Call the external onKeyDown handler first
onKeyDown?.(e);
// If the external handler prevented default, don't run internal logic
if (e.defaultPrevented) {
return;
}
@@ -983,7 +929,6 @@ export const PromptInputTextarea = ({
}
e.preventDefault();
// Check if the submit button is disabled before submitting
const { form } = e.currentTarget;
const submitButton = form?.querySelector(
'button[type="submit"]'
@@ -995,7 +940,6 @@ export const PromptInputTextarea = ({
form?.requestSubmit();
}
// Remove last attachment when Backspace is pressed and textarea is empty
if (
e.key === "Backspace" &&
e.currentTarget.value === "" &&
@@ -1205,9 +1149,6 @@ export const PromptInputActionMenuItem = ({
<DropdownMenuItem className={cn(className)} {...props} />
);
// Note: Actions that perform side-effects (like opening a file dialog)
// are provided in opt-in modules (e.g., prompt-input-attachments).
export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
status?: ChatStatus;
onStop?: () => void;
@@ -1364,7 +1305,6 @@ export const PromptInputTabLabel = ({
className,
...props
}: PromptInputTabLabelProps) => (
// Content provided via children in props
// oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)
<h3
className={cn(