Update React Editor integration and clean up template
This commit is contained in:
@@ -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 = ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user