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
-44
View File
@@ -20,10 +20,6 @@ import {
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import { createContext, useCallback, useContext, useMemo } from "react";
// ============================================================================
// Types
// ============================================================================
export type AttachmentData =
| (FileUIPart & { id: string })
| (SourceDocumentUIPart & { id: string });
@@ -47,10 +43,6 @@ const mediaCategoryIcons: Record<AttachmentMediaCategory, typeof ImageIcon> = {
video: VideoIcon,
};
// ============================================================================
// Utility Functions
// ============================================================================
export const getMediaCategory = (
data: AttachmentData
): AttachmentMediaCategory => {
@@ -108,10 +100,6 @@ const renderAttachmentImage = (
/>
);
// ============================================================================
// Contexts
// ============================================================================
interface AttachmentsContextValue {
variant: AttachmentVariant;
}
@@ -127,10 +115,6 @@ interface AttachmentContextValue {
const AttachmentContext = createContext<AttachmentContextValue | null>(null);
// ============================================================================
// Hooks
// ============================================================================
export const useAttachmentsContext = () =>
useContext(AttachmentsContext) ?? { variant: "grid" as const };
@@ -142,10 +126,6 @@ export const useAttachmentContext = () => {
return ctx;
};
// ============================================================================
// Attachments - Container
// ============================================================================
export type AttachmentsProps = HTMLAttributes<HTMLDivElement> & {
variant?: AttachmentVariant;
};
@@ -175,10 +155,6 @@ export const Attachments = ({
);
};
// ============================================================================
// Attachment - Item
// ============================================================================
export type AttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: AttachmentData;
onRemove?: () => void;
@@ -225,10 +201,6 @@ export const Attachment = ({
);
};
// ============================================================================
// AttachmentPreview - Media preview
// ============================================================================
export type AttachmentPreviewProps = HTMLAttributes<HTMLDivElement> & {
fallbackIcon?: ReactNode;
};
@@ -275,10 +247,6 @@ export const AttachmentPreview = ({
);
};
// ============================================================================
// AttachmentInfo - Name and type display
// ============================================================================
export type AttachmentInfoProps = HTMLAttributes<HTMLDivElement> & {
showMediaType?: boolean;
};
@@ -307,10 +275,6 @@ export const AttachmentInfo = ({
);
};
// ============================================================================
// AttachmentRemove - Remove button
// ============================================================================
export type AttachmentRemoveProps = ComponentProps<typeof Button> & {
label?: string;
};
@@ -365,10 +329,6 @@ export const AttachmentRemove = ({
);
};
// ============================================================================
// AttachmentHoverCard - Hover preview
// ============================================================================
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>;
export const AttachmentHoverCard = ({
@@ -403,10 +363,6 @@ export const AttachmentHoverCardContent = ({
/>
);
// ============================================================================
// AttachmentEmpty - Empty state
// ============================================================================
export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>;
export const AttachmentEmpty = ({
+4 -30
View File
@@ -24,16 +24,12 @@ import remarkRehype from "remark-rehype";
import remend from "remend";
import { unified } from "unified";
// Raw HTML never reaches the tree: remark-rehype drops it (allowDangerousHtml
// is off by default) and rehype-sanitize is the second line of defence, mainly
// for its href protocol allow-list — that is what stops `javascript:` URLs.
// Relative hrefs carry no protocol, so storefront links pass through untouched.
// Sanitize model output and reject unsafe URL protocols.
const schema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
// GFM task lists render as disabled checkboxes. The default schema allows
// `type` and `disabled` but not `checked`, so every box would read unticked.
// Preserve checked state for sanitized GFM task lists.
input: [...(defaultSchema.attributes?.input ?? []), "checked"],
},
};
@@ -44,22 +40,15 @@ const processor = unified()
.use(remarkRehype)
.use(rehypeSanitize, schema);
// `text-only` leaves a half-streamed `[label](htt` as plain text. remend's
// default instead emits a `streamdown:incomplete-link` placeholder href, which
// the sanitizer would strip anyway.
// Keep incomplete links as text while streaming.
const REMEND_OPTIONS = { linkMode: "text-only" } as const;
// remend only ever runs on text that is still arriving. On finished text it can
// do damage: it reads `*$89*` as an unclosed italic (the `$` throws off its
// closing-delimiter scan) and appends a stray `*`, which then parses as an empty
// bullet. Prices in italics are ordinary storefront copy, so settled messages
// render verbatim and only the in-flight one gets repaired.
// Repair only streaming text; remend can corrupt settled italicized prices.
const repair = (markdown: string, isStreaming: boolean) =>
isStreaming ? remend(markdown, REMEND_OPTIONS) : markdown;
export interface LinkSafetyConfig {
enabled: boolean;
/** Return true for links that may open without a confirmation step. */
onLinkCheck?: (url: string) => boolean;
}
@@ -71,9 +60,6 @@ type AnchorProps = ComponentProps<"a"> & {
const LINK_CLASS =
"font-medium underline underline-offset-4 hover:text-foreground";
// Route-relative hrefs are the storefront links the assistant emits. Deciding
// this from the string alone keeps the server and client passes identical;
// `linkSafety.onLinkCheck` does the origin-aware check later, at click time.
const isRouteHref = (href: string) => /^[/#?]/.test(href);
const MarkdownLink = ({
@@ -82,8 +68,6 @@ const MarkdownLink = ({
onUntrusted,
...props
}: AnchorProps) => {
// Runs on click rather than on render so it can read `window.location`,
// which is unavailable during the server pass.
const handleClick = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
if (!href || !linkSafety?.enabled || !linkSafety.onLinkCheck) return;
@@ -95,8 +79,6 @@ const MarkdownLink = ({
[href, linkSafety, onUntrusted]
);
// In-app destinations navigate client-side in the same tab; sending a shopper
// to a product page in a new tab would strand the conversation behind it.
if (href && isRouteHref(href)) {
return <Link className={LINK_CLASS} href={href} {...props} />;
}
@@ -113,7 +95,6 @@ const MarkdownLink = ({
);
};
// Fenced code renders as plain preformatted text — no tokenizer, no themes.
const buildComponents = (
linkSafety: LinkSafetyConfig | undefined,
onUntrusted: (url: string) => void
@@ -128,8 +109,6 @@ const buildComponents = (
/>
),
code: ({ className, ...props }: ComponentProps<"code">) => {
// Only fenced blocks carry a language class, and those already sit inside a
// <pre>, so they must not get the inline pill treatment.
const isBlock =
typeof className === "string" && className.includes("language-");
@@ -167,8 +146,6 @@ const buildComponents = (
hr: (props: ComponentProps<"hr">) => (
<hr className="my-4 border-border" {...props} />
),
// GFM task-list boxes are display only; readOnly silences React's warning
// about a `checked` input with no change handler.
input: (props: ComponentProps<"input">) => (
<input className="mr-1.5 align-middle" readOnly {...props} />
),
@@ -213,7 +190,6 @@ const buildComponents = (
export type MarkdownProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
children: string;
/** True while tokens are still arriving; enables incomplete-syntax repair. */
isAnimating?: boolean;
linkSafety?: LinkSafetyConfig;
};
@@ -231,8 +207,6 @@ export const Markdown = memo(
const handleUntrusted = useCallback((url: string) => setPending(url), []);
const content = useMemo(() => {
// While streaming, close syntax the model has not finished emitting so a
// partial `**bold` renders as bold rather than as literal asterisks.
const source = repair(children ?? "", isAnimating === true);
const tree = processor.runSync(processor.parse(source)) as Nodes;
-3
View File
@@ -202,7 +202,6 @@ export const MessageBranchContent = ({
[children]
);
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
@@ -231,7 +230,6 @@ export const MessageBranchSelector = ({
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
@@ -317,7 +315,6 @@ export const MessageBranchPage = ({
export type MessageResponseProps = ComponentProps<typeof Markdown>;
// Markdown already memoises on children/isAnimating.
export const MessageResponse = Markdown;
export type MessageToolbarProps = ComponentProps<"div">;
+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(
-4
View File
@@ -63,7 +63,6 @@ export const Reasoning = memo(
...props
}: ReasoningProps) => {
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
const isExplicitlyClosed = defaultOpen === false;
const [isOpen, setIsOpen] = useControllableState<boolean>({
@@ -80,7 +79,6 @@ export const Reasoning = memo(
const [hasAutoClosed, setHasAutoClosed] = useState(false);
const startTimeRef = useRef<number | null>(null);
// Track when streaming starts and compute duration
useEffect(() => {
if (isStreaming) {
hasEverStreamedRef.current = true;
@@ -93,14 +91,12 @@ export const Reasoning = memo(
}
}, [isStreaming, setDuration]);
// Auto-open when streaming starts (unless explicitly closed)
useEffect(() => {
if (isStreaming && !isOpen && !isExplicitlyClosed) {
setIsOpen(true);
}
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
// Auto-close when streaming ends (once only, and only if it ever streamed)
useEffect(() => {
if (
hasEverStreamedRef.current &&
-1
View File
@@ -8,7 +8,6 @@ import { memo, useMemo } from "react";
type MotionHTMLProps = MotionProps & Record<string, unknown>;
// Cache motion components at module level to avoid creating during render
const motionComponentCache = new Map<
keyof JSX.IntrinsicElements,
React.ComponentType<MotionHTMLProps>