diff --git a/app/(chat)/chat/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx index 67e0859135..cebf8d5056 100644 --- a/app/(chat)/chat/[id]/page.tsx +++ b/app/(chat)/chat/[id]/page.tsx @@ -1,3 +1,10 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Chat", + description: "Continue your conversation", +}; + export default function Page() { return null; } diff --git a/app/(chat)/page.tsx b/app/(chat)/page.tsx index 67e0859135..7ef0d4e8be 100644 --- a/app/(chat)/page.tsx +++ b/app/(chat)/page.tsx @@ -1,3 +1,10 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Chat", + description: "Start a new conversation", +}; + export default function Page() { return null; } diff --git a/app/globals.css b/app/globals.css index 4143774692..dfa24b3810 100644 --- a/app/globals.css +++ b/app/globals.css @@ -498,3 +498,14 @@ textarea:focus-visible { [data-testid="artifact"] { isolation: isolate; } + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index d427e99ea7..59041cc9ac 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import Script from "next/script"; import { ThemeProvider } from "@/components/theme-provider"; +import { MotionProvider } from "@/components/motion-provider"; import { TooltipProvider } from "@/components/ui/tooltip"; import "./globals.css"; @@ -60,12 +62,9 @@ export default function RootLayout({ suppressHydrationWarning > - - {children} + + {children} + diff --git a/components/ai-elements/model-selector.tsx b/components/ai-elements/model-selector.tsx index 48d7223bc8..c1e2d14420 100644 --- a/components/ai-elements/model-selector.tsx +++ b/components/ai-elements/model-selector.tsx @@ -1,3 +1,4 @@ +import Image from "next/image"; import type { ComponentProps, ReactNode } from "react"; import { @@ -173,12 +174,13 @@ export const ModelSelectorLogo = ({ className, ...props }: ModelSelectorLogoProps) => ( - {`${provider} ); diff --git a/components/ai-elements/prompt-input.tsx b/components/ai-elements/prompt-input.tsx index 563e365c37..603cfd275f 100644 --- a/components/ai-elements/prompt-input.tsx +++ b/components/ai-elements/prompt-input.tsx @@ -388,39 +388,30 @@ export type PromptInputProps = Omit< ) => void | Promise; }; -export const PromptInput = ({ - className, +// ============================================================================ +// Extracted Hooks +// ============================================================================ + +function usePromptInputFiles({ + controller, + usingProvider, accept, - multiple, - globalDrop, - syncHiddenInput, maxFiles, maxFileSize, onError, - onSubmit, - children, - ...props -}: PromptInputProps) => { - // Try to use a provider controller if present - const controller = useOptionalPromptInputController(); - const usingProvider = !!controller; - - // Refs +}: { + controller: PromptInputControllerProps | null; + usingProvider: boolean; + accept?: string; + maxFiles?: number; + maxFileSize?: number; + onError?: PromptInputProps["onError"]; +}) { const inputRef = useRef(null); - const formRef = useRef(null); - - // ----- Local attachments (only used when no provider) const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); - const files = usingProvider ? controller.attachments.files : items; + 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(() => { filesRef.current = files; }, [files]); @@ -431,20 +422,11 @@ export const PromptInput = ({ const matchesAccept = useCallback( (f: File) => { - if (!accept || accept.trim() === "") { - return true; - } - - const patterns = accept - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - + if (!accept || accept.trim() === "") return true; + const patterns = accept.split(",").map((s) => s.trim()).filter(Boolean); return patterns.some((pattern) => { if (pattern.endsWith("/*")) { - // e.g: image/* -> image/ - const prefix = pattern.slice(0, -1); - return f.type.startsWith(prefix); + return f.type.startsWith(pattern.slice(0, -1)); } return f.type === pattern; }); @@ -452,111 +434,77 @@ export const PromptInput = ({ [accept] ); - const addLocal = useCallback( + const validateAndFilter = useCallback( (fileList: File[] | FileList) => { const incoming = [...fileList]; const accepted = incoming.filter((f) => matchesAccept(f)); if (incoming.length && accepted.length === 0) { - onError?.({ - code: "accept", - message: "No files match the accepted types.", - }); - return; + onError?.({ code: "accept", message: "No files match the accepted types." }); + return null; } - const withinSize = (f: File) => - maxFileSize ? f.size <= maxFileSize : true; + const withinSize = (f: File) => (maxFileSize ? f.size <= maxFileSize : true); const sized = accepted.filter(withinSize); if (accepted.length > 0 && sized.length === 0) { - onError?.({ - code: "max_file_size", - message: "All files exceed the maximum size.", - }); - return; + onError?.({ code: "max_file_size", message: "All files exceed the maximum size." }); + return null; } + return sized; + }, + [matchesAccept, maxFileSize, onError] + ); + + const capFiles = useCallback( + (sized: File[], currentCount: number) => { + const capacity = + typeof maxFiles === "number" ? Math.max(0, maxFiles - currentCount) : 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." }); + } + return capped; + }, + [maxFiles, onError] + ); + const addLocal = useCallback( + (fileList: File[] | FileList) => { + const sized = validateAndFilter(fileList); + if (!sized) 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({ + const capped = capFiles(sized, prev.length); + return [ + ...prev, + ...capped.map((file) => ({ filename: file.name, id: nanoid(), mediaType: file.type, - type: "file", + type: "file" as const, url: URL.createObjectURL(file), - }); - } - return [...prev, ...next]; + })), + ]; }); }, - [matchesAccept, maxFiles, maxFileSize, onError] + [validateAndFilter, capFiles] ); const removeLocal = useCallback( (id: string) => setItems((prev) => { const found = prev.find((file) => file.id === id); - if (found?.url) { - URL.revokeObjectURL(found.url); - } + if (found?.url) URL.revokeObjectURL(found.url); return prev.filter((file) => file.id !== id); }), [] ); - // Wrapper that validates files before calling provider's add const addWithProviderValidation = useCallback( (fileList: File[] | FileList) => { - const incoming = [...fileList]; - const accepted = incoming.filter((f) => matchesAccept(f)); - if (incoming.length && accepted.length === 0) { - onError?.({ - code: "accept", - message: "No files match the accepted types.", - }); - return; - } - const withinSize = (f: File) => - maxFileSize ? f.size <= maxFileSize : true; - const sized = accepted.filter(withinSize); - if (accepted.length > 0 && sized.length === 0) { - onError?.({ - code: "max_file_size", - message: "All files exceed the maximum size.", - }); - return; - } - - const currentCount = files.length; - const capacity = - typeof maxFiles === "number" - ? Math.max(0, maxFiles - currentCount) - : 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 > 0) { - controller?.attachments.add(capped); - } + const sized = validateAndFilter(fileList); + if (!sized) return; + const capped = capFiles(sized, files.length); + if (capped.length > 0) controller?.attachments.add(capped); }, - [matchesAccept, maxFileSize, maxFiles, onError, files.length, controller] + [validateAndFilter, capFiles, files.length, controller] ); const clearAttachments = useCallback( @@ -565,70 +513,74 @@ export const PromptInput = ({ ? controller?.attachments.clear() : setItems((prev) => { for (const file of prev) { - if (file.url) { - URL.revokeObjectURL(file.url); - } + if (file.url) URL.revokeObjectURL(file.url); } return []; }), [usingProvider, controller] ); - const clearReferencedSources = useCallback( - () => setReferencedSources([]), - [] - ); - const add = usingProvider ? addWithProviderValidation : addLocal; - const remove = usingProvider ? controller.attachments.remove : removeLocal; + const remove = usingProvider ? controller!.attachments.remove : removeLocal; const openFileDialog = usingProvider - ? controller.attachments.openFileDialog + ? controller!.attachments.openFileDialog : openFileDialogLocal; - const clear = useCallback(() => { - clearAttachments(); - clearReferencedSources(); - }, [clearAttachments, clearReferencedSources]); + // Cleanup blob URLs on unmount + useEffect( + () => () => { + if (!usingProvider) { + for (const f of filesRef.current) { + if (f.url) URL.revokeObjectURL(f.url); + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current + [usingProvider] + ); - // Let provider know about our hidden file input so external menus can call openFileDialog() - useEffect(() => { - if (!usingProvider) { - return; - } - controller.__registerFileInput(inputRef, () => inputRef.current?.click()); - }, [usingProvider, controller]); + const handleChange: ChangeEventHandler = useCallback( + (event) => { + if (event.currentTarget.files) add(event.currentTarget.files); + event.currentTarget.value = ""; + }, + [add] + ); - // Note: File input cannot be programmatically set for security reasons - // The syncHiddenInput prop is no longer functional - useEffect(() => { - if (syncHiddenInput && inputRef.current && files.length === 0) { - inputRef.current.value = ""; - } - }, [files, syncHiddenInput]); + const attachmentsCtx = useMemo( + () => ({ + add, + clear: clearAttachments, + fileInputRef: inputRef, + files: files.map((item) => ({ ...item, id: item.id })), + openFileDialog, + remove, + }), + [files, add, remove, clearAttachments, openFileDialog] + ); + + return { inputRef, files, add, remove, openFileDialog, clearAttachments, handleChange, attachmentsCtx }; +} - // Attach drop handlers on nearest form and document (opt-in) +function useDropHandlers({ + formRef, + add, + globalDrop, +}: { + formRef: RefObject; + add: (files: File[] | FileList) => void; + globalDrop?: boolean; +}) { useEffect(() => { const form = formRef.current; - if (!form) { - return; - } - if (globalDrop) { - // when global drop is on, let the document-level handler own drops - return; - } + if (!form || globalDrop) return; const onDragOver = (e: DragEvent) => { - if (e.dataTransfer?.types?.includes("Files")) { - e.preventDefault(); - } + if (e.dataTransfer?.types?.includes("Files")) e.preventDefault(); }; const onDrop = (e: DragEvent) => { - if (e.dataTransfer?.types?.includes("Files")) { - e.preventDefault(); - } - if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { - add(e.dataTransfer.files); - } + if (e.dataTransfer?.types?.includes("Files")) e.preventDefault(); + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) add(e.dataTransfer.files); }; form.addEventListener("dragover", onDragOver); form.addEventListener("drop", onDrop); @@ -636,25 +588,17 @@ export const PromptInput = ({ form.removeEventListener("dragover", onDragOver); form.removeEventListener("drop", onDrop); }; - }, [add, globalDrop]); + }, [add, globalDrop, formRef]); useEffect(() => { - if (!globalDrop) { - return; - } + if (!globalDrop) return; const onDragOver = (e: DragEvent) => { - if (e.dataTransfer?.types?.includes("Files")) { - e.preventDefault(); - } + if (e.dataTransfer?.types?.includes("Files")) e.preventDefault(); }; const onDrop = (e: DragEvent) => { - if (e.dataTransfer?.types?.includes("Files")) { - e.preventDefault(); - } - if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { - add(e.dataTransfer.files); - } + if (e.dataTransfer?.types?.includes("Files")) e.preventDefault(); + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) add(e.dataTransfer.files); }; document.addEventListener("dragover", onDragOver); document.addEventListener("drop", onDrop); @@ -663,43 +607,59 @@ export const PromptInput = ({ document.removeEventListener("drop", onDrop); }; }, [add, globalDrop]); +} - useEffect( - () => () => { - if (!usingProvider) { - for (const f of filesRef.current) { - if (f.url) { - URL.revokeObjectURL(f.url); - } - } - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current - [usingProvider] - ); +// ============================================================================ +// PromptInput Component +// ============================================================================ - const handleChange: ChangeEventHandler = useCallback( - (event) => { - if (event.currentTarget.files) { - add(event.currentTarget.files); - } - // Reset input value to allow selecting files that were previously removed - event.currentTarget.value = ""; - }, - [add] - ); +export const PromptInput = ({ + className, + accept, + multiple, + globalDrop, + syncHiddenInput, + maxFiles, + maxFileSize, + onError, + onSubmit, + children, + ...props +}: PromptInputProps) => { + const controller = useOptionalPromptInputController(); + const usingProvider = !!controller; + const formRef = useRef(null); - const attachmentsCtx = useMemo( - () => ({ - add, - clear: clearAttachments, - fileInputRef: inputRef, - files: files.map((item) => ({ ...item, id: item.id })), - openFileDialog, - remove, - }), - [files, add, remove, clearAttachments, openFileDialog] - ); + const { + inputRef, files, add, remove, openFileDialog, + clearAttachments, handleChange, attachmentsCtx, + } = usePromptInputFiles({ controller, usingProvider, accept, maxFiles, maxFileSize, onError }); + + // ----- Local referenced sources (always local to PromptInput) + const [referencedSources, setReferencedSources] = useState< + (SourceDocumentUIPart & { id: string })[] + >([]); + + const clearReferencedSources = useCallback(() => setReferencedSources([]), []); + + const clear = useCallback(() => { + clearAttachments(); + clearReferencedSources(); + }, [clearAttachments, clearReferencedSources]); + + // Let provider know about our hidden file input so external menus can call openFileDialog() + useEffect(() => { + if (!usingProvider) return; + controller.__registerFileInput(inputRef, () => inputRef.current?.click()); + }, [usingProvider, controller, inputRef]); + + useEffect(() => { + if (syncHiddenInput && inputRef.current && files.length === 0) { + inputRef.current.value = ""; + } + }, [files, syncHiddenInput, inputRef]); + + useDropHandlers({ formRef, add, globalDrop }); const refsCtx = useMemo( () => ({ @@ -731,23 +691,14 @@ 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 - if (!usingProvider) { - form.reset(); - } + 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, - }; + return { ...item, url: dataUrl ?? item.url }; } return item; }) @@ -755,23 +706,17 @@ export const PromptInput = ({ const result = onSubmit({ files: convertedFiles, text }, event); - // Handle both sync and async onSubmit if (result instanceof Promise) { try { await result; clear(); - if (usingProvider) { - controller.textInput.clear(); - } + if (usingProvider) controller.textInput.clear(); } catch { // Don't clear on error - user may want to retry } } else { - // Sync function completed without throwing, clear inputs clear(); - if (usingProvider) { - controller.textInput.clear(); - } + if (usingProvider) controller.textInput.clear(); } } catch { // Don't clear on error - user may want to retry @@ -780,40 +725,28 @@ export const PromptInput = ({ [usingProvider, controller, files, onSubmit, clear] ); - // Render with or without local provider - const inner = ( - <> - -
- {children} -
- - ); - - const withReferencedSources = ( - - {inner} - - ); - - // Always provide LocalAttachmentsContext so children get validated add function return ( - {withReferencedSources} + + +
+ {children} +
+
); }; @@ -1240,17 +1173,18 @@ export type PromptInputTabLabelProps = HTMLAttributes; export const PromptInputTabLabel = ({ className, + children, ...props }: PromptInputTabLabelProps) => ( - // Content provided via children in props - // oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)

+ > + {children} +

); export type PromptInputTabBodyProps = HTMLAttributes; diff --git a/components/ai-elements/shimmer.tsx b/components/ai-elements/shimmer.tsx index 5962cf829e..c7dfe11ae7 100644 --- a/components/ai-elements/shimmer.tsx +++ b/components/ai-elements/shimmer.tsx @@ -1,32 +1,13 @@ "use client"; -import type { MotionProps } from "motion/react"; -import type { CSSProperties, ElementType, JSX } from "react"; +import type { CSSProperties } from "react"; import { cn } from "@/lib/utils"; -import { motion } from "motion/react"; +import { m } from "framer-motion"; import { memo, useMemo } from "react"; -type MotionHTMLProps = MotionProps & Record; - -// Cache motion components at module level to avoid creating during render -const motionComponentCache = new Map< - keyof JSX.IntrinsicElements, - React.ComponentType ->(); - -const getMotionComponent = (element: keyof JSX.IntrinsicElements) => { - let component = motionComponentCache.get(element); - if (!component) { - component = motion.create(element); - motionComponentCache.set(element, component); - } - return component; -}; - export interface TextShimmerProps { children: string; - as?: ElementType; className?: string; duration?: number; spread?: number; @@ -34,22 +15,17 @@ export interface TextShimmerProps { const ShimmerComponent = ({ children, - as: Component = "p", className, duration = 2, spread = 2, }: TextShimmerProps) => { - const MotionComponent = getMotionComponent( - Component as keyof JSX.IntrinsicElements - ); - const dynamicSpread = useMemo( () => (children?.length ?? 0) * spread, [children, spread] ); return ( - {children} - + ); }; diff --git a/components/chat/artifact-actions.tsx b/components/chat/artifact-actions.tsx index 880df43307..98ef59c8e3 100644 --- a/components/chat/artifact-actions.tsx +++ b/components/chat/artifact-actions.tsx @@ -76,9 +76,8 @@ function PureArtifactActions({ await Promise.resolve(action.onClick(actionContext)); } catch (_error) { toast.error("Failed to execute action"); - } finally { - setIsLoading(false); } + setIsLoading(false); }} type="button" > diff --git a/components/chat/artifact-messages.tsx b/components/chat/artifact-messages.tsx index 3cfa972b3d..5232742e9c 100644 --- a/components/chat/artifact-messages.tsx +++ b/components/chat/artifact-messages.tsx @@ -1,6 +1,6 @@ import type { UseChatHelpers } from "@ai-sdk/react"; import equal from "fast-deep-equal"; -import { AnimatePresence, motion } from "framer-motion"; +import { AnimatePresence, m } from "framer-motion"; import { memo } from "react"; import { useMessages } from "@/hooks/use-messages"; import type { Vote } from "@/lib/db/schema"; @@ -75,7 +75,7 @@ function PureArtifactMessages({ ) && } - +
+ +
+
+ {artifact.title} +
+
+ {isContentDirty ? ( +
+
+ Saving... +
+ ) : document ? ( +
+ {`Updated ${formatDistance(new Date(document.createdAt), new Date(), { addSuffix: true })}`} +
+ ) : artifact.status === "streaming" ? ( +
+
+ +
+ Generating... +
+ ) : ( +
+ )} + {documents && documents.length > 1 && ( +
+ v{currentVersionIndex + 1}/{documents.length} +
+ )} +
+
+
+
+ ); +} + +function ArtifactContentArea({ + artifactContentRef, + onContentScroll, + artifactDefinition, + isCurrentVersion, + artifact, + getDocumentContentById, + currentVersionIndex, + isDocumentsFetching, + metadata, + mode, + saveContent, + setMetadata, + isToolbarVisible, + setIsToolbarVisible, + setArtifact, sendMessage, - messages: _messages, setMessages, - regenerate: _regenerate, - votes: _votes, - isReadonly: _isReadonly, - selectedVisibilityType: _selectedVisibilityType, - selectedModelId: _selectedModelId, + status, + stop, + consoleError, + handleVersionChange, + documents, + dispatchDoc, }: { - addToolApprovalResponse: UseChatHelpers["addToolApprovalResponse"]; - chatId: string; - input: string; - setInput: Dispatch>; + artifactContentRef: React.MutableRefObject; + onContentScroll: () => void; + artifactDefinition: (typeof artifactDefinitions)[number]; + isCurrentVersion: boolean; + artifact: UIArtifact; + getDocumentContentById: (index: number) => string; + currentVersionIndex: number; + isDocumentsFetching: boolean; + metadata: any; + mode: "edit" | "diff"; + saveContent: (updatedContent: string, debounce: boolean) => void; + setMetadata: any; + isToolbarVisible: boolean; + setIsToolbarVisible: Dispatch>; + setArtifact: Dispatch>; + sendMessage: UseChatHelpers["sendMessage"]; + setMessages: UseChatHelpers["setMessages"]; status: UseChatHelpers["status"]; stop: UseChatHelpers["stop"]; - attachments: Attachment[]; - setAttachments: Dispatch>; - messages: ChatMessage[]; - setMessages: UseChatHelpers["setMessages"]; - votes: Vote[] | undefined; - sendMessage: UseChatHelpers["sendMessage"]; - regenerate: UseChatHelpers["regenerate"]; - isReadonly: boolean; - selectedVisibilityType: VisibilityType; - selectedModelId: string; + consoleError: string | undefined; + handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void; + documents: Document[] | undefined; + dispatchDoc: Dispatch; }) { - const { artifact, setArtifact, metadata, setMetadata } = useArtifact(); + return ( + <> +
+ + + {isCurrentVersion && ( + + } + artifactKind={artifact.kind} + consoleError={consoleError} + documentId={artifact.documentId} + isToolbarVisible={isToolbarVisible} + onClose={() => { + setArtifact((prev) => ({ ...prev, isVisible: false })); + }} + sendMessage={sendMessage} + setIsToolbarVisible={setIsToolbarVisible} + setMessages={setMessages} + status={status} + stop={stop} + /> + )} + +
+ + {!isCurrentVersion && ( + dispatchDoc({ type: "SET_MODE", mode: m })} + /> + )} + + + ); +} +function useArtifactDocuments(artifact: UIArtifact, setArtifact: Dispatch>) { const { data: documents, isLoading: isDocumentsFetching, @@ -100,37 +270,21 @@ function PureArtifact({ fetcher ); - const [mode, setMode] = useState<"edit" | "diff">("edit"); - const [document, setDocument] = useState(null); - const [currentVersionIndex, setCurrentVersionIndex] = useState(-1); - - const { state: sidebarState } = useSidebar(); - const artifactContentRef = useRef(null); - const userScrolledArtifact = useRef(false); - const [isContentDirty, setIsContentDirty] = useState(false); - - useEffect(() => { - if (artifact.status !== "streaming") { - userScrolledArtifact.current = false; - return; - } - if (userScrolledArtifact.current) { - return; - } - const el = artifactContentRef.current; - if (!el) { - return; - } - el.scrollTo({ top: el.scrollHeight }); - }, [artifact.status]); - - useEffect(() => { + const [docState, dispatchDoc] = useReducer(artifactDocReducer, { + mode: "edit", + document: null, + currentVersionIndex: -1, + isContentDirty: false, + }); + const { mode, document, currentVersionIndex, isContentDirty } = docState; + + const [prevDocuments, setPrevDocuments] = useState(undefined); + if (documents !== prevDocuments) { + setPrevDocuments(documents); if (documents && documents.length > 0) { const mostRecentDocument = documents.at(-1); - if (mostRecentDocument) { - setDocument(mostRecentDocument); - setCurrentVersionIndex(documents.length - 1); + dispatchDoc({ type: "SET_DOCUMENT", document: mostRecentDocument, versionIndex: documents.length - 1 }); if (artifact.status === "streaming" || !isContentDirty) { setArtifact((currentArtifact) => ({ ...currentArtifact, @@ -139,7 +293,7 @@ function PureArtifact({ } } } - }, [documents, setArtifact, artifact.status, isContentDirty]); + } useEffect(() => { mutateDocuments(); @@ -149,48 +303,33 @@ function PureArtifact({ const handleContentChange = useCallback( (updatedContent: string) => { - if (!artifact) { - return; - } - + if (!artifact) return; + const docUrl = `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}`; mutate( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}`, + docUrl, async (currentDocuments) => { - if (!currentDocuments) { - return []; - } - + if (!currentDocuments) return []; const currentDocument = currentDocuments.at(-1); - if (!currentDocument || !currentDocument.content) { - setIsContentDirty(false); + dispatchDoc({ type: "SET_CONTENT_DIRTY", dirty: false }); return currentDocuments; } - if (currentDocument.content === updatedContent) { - setIsContentDirty(false); + dispatchDoc({ type: "SET_CONTENT_DIRTY", dirty: false }); return currentDocuments; } - - await fetch( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}`, - { - method: "POST", - body: JSON.stringify({ - title: artifact.title, - content: updatedContent, - kind: artifact.kind, - isManualEdit: true, - }), - } - ); - - setIsContentDirty(false); - + await fetch(docUrl, { + method: "POST", + body: JSON.stringify({ + title: artifact.title, + content: updatedContent, + kind: artifact.kind, + isManualEdit: true, + }), + }); + dispatchDoc({ type: "SET_CONTENT_DIRTY", dirty: false }); return currentDocuments.map((doc, i) => - i === currentDocuments.length - 1 - ? { ...doc, content: updatedContent } - : doc + i === currentDocuments.length - 1 ? { ...doc, content: updatedContent } : doc ); }, { revalidate: false } @@ -205,13 +344,11 @@ function PureArtifact({ const saveContent = useCallback( (updatedContent: string, debounce: boolean) => { latestContentRef.current = updatedContent; - setIsContentDirty(true); - + dispatchDoc({ type: "SET_CONTENT_DIRTY", dirty: true }); if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); saveTimerRef.current = null; } - if (debounce) { saveTimerRef.current = setTimeout(() => { handleContentChange(latestContentRef.current); @@ -224,46 +361,102 @@ function PureArtifact({ [handleContentChange] ); - function getDocumentContentById(index: number) { - if (!documents) { - return ""; - } - if (!documents[index]) { - return ""; - } - return documents[index].content ?? ""; - } + const getDocumentContentById = useCallback( + (index: number) => documents?.[index]?.content ?? "", + [documents] + ); - const handleVersionChange = (type: "next" | "prev" | "toggle" | "latest") => { - if (!documents) { - return; - } + const handleVersionChange = useCallback( + (type: "next" | "prev" | "toggle" | "latest") => { + if (!documents) return; + if (type === "latest") { + dispatchDoc({ type: "SET_VERSION_INDEX", index: documents.length - 1 }); + dispatchDoc({ type: "SET_MODE", mode: "edit" }); + } + if (type === "toggle") { + dispatchDoc({ type: "SET_MODE", mode: mode === "edit" ? "diff" : "edit" }); + } + if (type === "prev") { + if (currentVersionIndex > 0) { + dispatchDoc({ type: "SET_VERSION_INDEX", index: currentVersionIndex - 1 }); + } + } else if (type === "next" && currentVersionIndex < documents.length - 1) { + dispatchDoc({ type: "SET_VERSION_INDEX", index: currentVersionIndex + 1 }); + } + }, + [documents, mode, currentVersionIndex] + ); - if (type === "latest") { - setCurrentVersionIndex(documents.length - 1); - setMode("edit"); - } + const isCurrentVersion = + documents && documents.length > 0 ? currentVersionIndex === documents.length - 1 : true; - if (type === "toggle") { - setMode((currentMode) => (currentMode === "edit" ? "diff" : "edit")); - } + return { + documents, isDocumentsFetching, docState, dispatchDoc, + mode, document, currentVersionIndex, isContentDirty, + saveContent, getDocumentContentById, handleVersionChange, isCurrentVersion, + }; +} - if (type === "prev") { - if (currentVersionIndex > 0) { - setCurrentVersionIndex((index) => index - 1); - } - } else if (type === "next" && currentVersionIndex < documents.length - 1) { - setCurrentVersionIndex((index) => index + 1); +function PureArtifact({ + addToolApprovalResponse: _addToolApprovalResponse, + chatId: _chatId, + input: _input, + setInput: _setInput, + status, + stop, + attachments: _attachments, + setAttachments: _setAttachments, + sendMessage, + messages: _messages, + setMessages, + regenerate: _regenerate, + votes: _votes, + isReadonly: _isReadonly, + selectedVisibilityType: _selectedVisibilityType, + selectedModelId: _selectedModelId, +}: { + addToolApprovalResponse: UseChatHelpers["addToolApprovalResponse"]; + chatId: string; + input: string; + setInput: (val: string) => void; + status: UseChatHelpers["status"]; + stop: UseChatHelpers["stop"]; + attachments: Attachment[]; + setAttachments: Dispatch>; + messages: ChatMessage[]; + setMessages: UseChatHelpers["setMessages"]; + votes: Vote[] | undefined; + sendMessage: UseChatHelpers["sendMessage"]; + regenerate: UseChatHelpers["regenerate"]; + isReadonly: boolean; + selectedVisibilityType: VisibilityType; + selectedModelId: string; +}) { + const { artifact, setArtifact, metadata, setMetadata } = useArtifact(); + + const { + documents, isDocumentsFetching, dispatchDoc, + mode, document, currentVersionIndex, isContentDirty, + saveContent, getDocumentContentById, handleVersionChange, isCurrentVersion, + } = useArtifactDocuments(artifact, setArtifact); + + const { state: sidebarState } = useSidebar(); + const artifactContentRef = useRef(null); + const userScrolledArtifact = useRef(false); + + useEffect(() => { + if (artifact.status !== "streaming") { + userScrolledArtifact.current = false; + return; } - }; + if (userScrolledArtifact.current) return; + const el = artifactContentRef.current; + if (!el) return; + el.scrollTo({ top: el.scrollHeight }); + }, [artifact.status]); const [isToolbarVisible, setIsToolbarVisible] = useState(true); - const isCurrentVersion = - documents && documents.length > 0 - ? currentVersionIndex === documents.length - 1 - : true; - const { width: windowWidth, height: windowHeight } = useWindowSize(); const isMobile = windowWidth ? windowWidth < 768 : false; @@ -308,123 +501,50 @@ function PureArtifact({ const artifactPanel = ( <> {sidebarState !== "collapsed" && ( -
-
- -
-
- {artifact.title} -
-
- {isContentDirty ? ( -
-
- Saving... -
- ) : document ? ( -
- {`Updated ${formatDistance(new Date(document.createdAt), new Date(), { addSuffix: true })}`} -
- ) : artifact.status === "streaming" ? ( -
-
- -
- Generating... -
- ) : ( -
- )} - {documents && documents.length > 1 && ( -
- v{currentVersionIndex + 1}/{documents.length} -
- )} -
-
-
-
+ )} -
{ + { const el = artifactContentRef.current; - if (!el) { - return; - } - const atBottom = - el.scrollHeight - el.scrollTop - el.clientHeight < 40; + if (!el) return; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; userScrolledArtifact.current = !atBottom; }} - ref={artifactContentRef} - > - - - {isCurrentVersion && ( - - } - artifactKind={artifact.kind} - consoleError={consoleError} - documentId={artifact.documentId} - isToolbarVisible={isToolbarVisible} - onClose={() => { - setArtifact((prev) => ({ ...prev, isVisible: false })); - }} - sendMessage={sendMessage} - setIsToolbarVisible={setIsToolbarVisible} - setMessages={setMessages} - status={status} - stop={stop} - /> - )} - -
- - {!isCurrentVersion && ( - - )} - + /> ); if (isMobile) { return ( - {artifactPanel} - + ); } diff --git a/components/chat/auth-form.tsx b/components/chat/auth-form.tsx index 43367dc791..fccaf51a3d 100644 --- a/components/chat/auth-form.tsx +++ b/components/chat/auth-form.tsx @@ -22,7 +22,6 @@ export function AuthForm({ {consoleOutput.contents.map((content) => content.type === "image" ? ( - - output - + src={content.value} + unoptimized + width={600} + /> ) : (
{ return (
- What can I help with? - - + Ask a question, write code, or explore ideas. - +
); }; diff --git a/components/chat/image-editor.tsx b/components/chat/image-editor.tsx index 32525fcf3f..fd662d0daf 100644 --- a/components/chat/image-editor.tsx +++ b/components/chat/image-editor.tsx @@ -1,4 +1,5 @@ import cn from "classnames"; +import Image from "next/image"; import { LoaderIcon } from "./icons"; type ImageEditorProps = { @@ -33,15 +34,16 @@ export function ImageEditor({
Generating Image...
) : ( - - {title} - + {title} )}
); diff --git a/components/chat/message-reasoning.tsx b/components/chat/message-reasoning.tsx index f834ce4e06..a3744d2245 100644 --- a/components/chat/message-reasoning.tsx +++ b/components/chat/message-reasoning.tsx @@ -1,6 +1,5 @@ "use client"; -import { useEffect, useState } from "react"; import { Reasoning, ReasoningContent, @@ -16,18 +15,12 @@ export function MessageReasoning({ isLoading, reasoning, }: MessageReasoningProps) { - const [hasBeenStreaming, setHasBeenStreaming] = useState(isLoading); - - useEffect(() => { - if (isLoading) { - setHasBeenStreaming(true); - } - }, [isLoading]); + const hasReasoning = isLoading || reasoning.length > 0; return ( diff --git a/components/chat/multimodal-input.tsx b/components/chat/multimodal-input.tsx index bb07f7cc42..bc979e77ec 100644 --- a/components/chat/multimodal-input.tsx +++ b/components/chat/multimodal-input.tsx @@ -68,6 +68,251 @@ function setCookie(name: string, value: string) { document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}`; } +function useFileUpload( + setAttachments: Dispatch>, + textareaRef: React.RefObject +) { + const fileInputRef = useRef(null); + const [uploadQueue, setUploadQueue] = useState([]); + + const uploadFile = useCallback(async (file: File) => { + const formData = new FormData(); + formData.append("file", file); + const uploadUrl = `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/files/upload`; + + try { + const response = await fetch(uploadUrl, { + method: "POST", + body: formData, + }); + + if (response.ok) { + const data = await response.json(); + const { url, pathname, contentType } = data; + + return { + url, + name: pathname, + contentType, + }; + } + const { error } = await response.json(); + toast.error(error); + } catch (_error) { + toast.error("Failed to upload file, please try again!"); + } + }, []); + + const handleFileChange = useCallback( + async (event: ChangeEvent) => { + const files = Array.from(event.target.files || []); + + setUploadQueue(files.map((file) => file.name)); + + try { + const uploadPromises = files.map((file) => uploadFile(file)); + const uploadedAttachments = await Promise.all(uploadPromises); + const successfullyUploadedAttachments = uploadedAttachments.filter( + (attachment) => attachment !== undefined + ); + + setAttachments((currentAttachments) => [ + ...currentAttachments, + ...successfullyUploadedAttachments, + ]); + } catch (_error) { + toast.error("Failed to upload files"); + } + setUploadQueue([]); + }, + [setAttachments, setUploadQueue, uploadFile] + ); + + const handlePaste = useCallback( + async (event: ClipboardEvent) => { + const items = event.clipboardData?.items; + if (!items) { + return; + } + + const imageItems = Array.from(items).filter((item) => + item.type.startsWith("image/") + ); + + if (imageItems.length === 0) { + return; + } + + event.preventDefault(); + + setUploadQueue((prev) => [...prev, "Pasted image"]); + + try { + const uploadPromises = imageItems + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null) + .map((file) => uploadFile(file)); + + const uploadedAttachments = await Promise.all(uploadPromises); + const successfullyUploadedAttachments = uploadedAttachments.filter( + (attachment) => + attachment !== undefined && + attachment.url !== undefined && + attachment.contentType !== undefined + ); + + setAttachments((curr) => [ + ...curr, + ...(successfullyUploadedAttachments as Attachment[]), + ]); + } catch (_error) { + toast.error("Failed to upload pasted image(s)"); + } + setUploadQueue([]); + }, + [setAttachments, setUploadQueue, uploadFile] + ); + + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) { + return; + } + + textarea.addEventListener("paste", handlePaste); + return () => textarea.removeEventListener("paste", handlePaste); + }, [handlePaste, textareaRef]); + + return { uploadQueue, fileInputRef, handleFileChange }; +} + +function useSlashCommands({ + setInput, + setLocalStorageInput, + setMessages, + router, + setTheme, + resolvedTheme, + chatId, +}: { + setInput: (val: string) => void; + setLocalStorageInput: (val: string) => void; + setMessages: UseChatHelpers["setMessages"]; + router: ReturnType; + setTheme: (theme: string) => void; + resolvedTheme: string | undefined; + chatId: string; +}) { + const [slashState, setSlashState] = useState({ open: false, query: "", index: 0 }); + + const handleSlashSelect = useCallback( + (cmd: SlashCommand) => { + setSlashState((s) => ({ ...s, open: false })); + setInput(""); + setLocalStorageInput(""); + switch (cmd.action) { + case "new": + router.push("/"); + break; + case "clear": + setMessages(() => []); + break; + case "rename": + toast("Rename is available from the sidebar chat menu."); + break; + case "model": { + const modelBtn = document.querySelector( + "[data-testid='model-selector']" + ); + modelBtn?.click(); + break; + } + case "theme": + setTheme(resolvedTheme === "dark" ? "light" : "dark"); + break; + case "delete": + toast("Delete this chat?", { + action: { + label: "Delete", + onClick: () => { + fetch( + `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chat?id=${chatId}`, + { method: "DELETE" } + ); + router.push("/"); + toast.success("Chat deleted"); + }, + }, + }); + break; + case "purge": + toast("Delete all chats?", { + action: { + label: "Delete all", + onClick: () => { + fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history`, { + method: "DELETE", + }); + router.push("/"); + toast.success("All chats deleted"); + }, + }, + }); + break; + default: + break; + } + }, + [setInput, setLocalStorageInput, setMessages, router, setTheme, resolvedTheme, chatId] + ); + + const handleInputSlash = useCallback( + (val: string) => { + if (val.startsWith("/") && !val.includes(" ")) { + setSlashState({ open: true, query: val.slice(1), index: 0 }); + } else { + setSlashState((s) => s.open ? { ...s, open: false } : s); + } + }, + [] + ); + + const handleSlashKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!slashState.open) return false; + const filtered = slashCommands.filter((cmd) => + cmd.name.startsWith(slashState.query.toLowerCase()) + ); + if (e.key === "ArrowDown") { + e.preventDefault(); + setSlashState((s) => ({ ...s, index: Math.min(s.index + 1, filtered.length - 1) })); + return true; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSlashState((s) => ({ ...s, index: Math.max(s.index - 1, 0) })); + return true; + } + if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault(); + if (filtered[slashState.index]) { + handleSlashSelect(filtered[slashState.index]); + } + return true; + } + if (e.key === "Escape") { + e.preventDefault(); + setSlashState((s) => ({ ...s, open: false })); + return true; + } + return false; + }, + [slashState, handleSlashSelect] + ); + + return { slashState, setSlashState, handleSlashSelect, handleInputSlash, handleSlashKeyDown }; +} + function PureMultimodalInput({ chatId, input, @@ -89,7 +334,7 @@ function PureMultimodalInput({ }: { chatId: string; input: string; - setInput: Dispatch>; + setInput: (val: string) => void; status: UseChatHelpers["status"]; stop: () => void; attachments: Attachment[]; @@ -127,94 +372,41 @@ function PureMultimodalInput({ "" ); - useEffect(() => { - if (textareaRef.current) { - const domValue = textareaRef.current.value; - const finalValue = domValue || localStorageInput || ""; - setInput(finalValue); - } - }, [localStorageInput, setInput]); + const [hasInitialized, setHasInitialized] = useState(false); - useEffect(() => { - setLocalStorageInput(input); - }, [input, setLocalStorageInput]); + if (!hasInitialized && localStorageInput) { + setHasInitialized(true); + setInput(localStorageInput); + } + + const { uploadQueue, fileInputRef, handleFileChange } = useFileUpload( + setAttachments, + textareaRef + ); + + const { + slashState, + setSlashState, + handleSlashSelect, + handleInputSlash, + handleSlashKeyDown, + } = useSlashCommands({ + setInput, + setLocalStorageInput, + setMessages, + router, + setTheme, + resolvedTheme, + chatId, + }); const handleInput = (event: React.ChangeEvent) => { const val = event.target.value; setInput(val); - - if (val.startsWith("/") && !val.includes(" ")) { - setSlashOpen(true); - setSlashQuery(val.slice(1)); - setSlashIndex(0); - } else { - setSlashOpen(false); - } + setLocalStorageInput(val); + handleInputSlash(val); }; - const handleSlashSelect = (cmd: SlashCommand) => { - setSlashOpen(false); - setInput(""); - switch (cmd.action) { - case "new": - router.push("/"); - break; - case "clear": - setMessages(() => []); - break; - case "rename": - toast("Rename is available from the sidebar chat menu."); - break; - case "model": { - const modelBtn = document.querySelector( - "[data-testid='model-selector']" - ); - modelBtn?.click(); - break; - } - case "theme": - setTheme(resolvedTheme === "dark" ? "light" : "dark"); - break; - case "delete": - toast("Delete this chat?", { - action: { - label: "Delete", - onClick: () => { - fetch( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chat?id=${chatId}`, - { method: "DELETE" } - ); - router.push("/"); - toast.success("Chat deleted"); - }, - }, - }); - break; - case "purge": - toast("Delete all chats?", { - action: { - label: "Delete all", - onClick: () => { - fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history`, { - method: "DELETE", - }); - router.push("/"); - toast.success("All chats deleted"); - }, - }, - }); - break; - default: - break; - } - }; - - const fileInputRef = useRef(null); - const [uploadQueue, setUploadQueue] = useState([]); - const [slashOpen, setSlashOpen] = useState(false); - const [slashQuery, setSlashQuery] = useState(""); - const [slashIndex, setSlashIndex] = useState(0); - const submitForm = useCallback(() => { window.history.pushState( {}, @@ -256,118 +448,6 @@ function PureMultimodalInput({ chatId, ]); - const uploadFile = useCallback(async (file: File) => { - const formData = new FormData(); - formData.append("file", file); - - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/files/upload`, - { - method: "POST", - body: formData, - } - ); - - if (response.ok) { - const data = await response.json(); - const { url, pathname, contentType } = data; - - return { - url, - name: pathname, - contentType, - }; - } - const { error } = await response.json(); - toast.error(error); - } catch (_error) { - toast.error("Failed to upload file, please try again!"); - } - }, []); - - const handleFileChange = useCallback( - async (event: ChangeEvent) => { - const files = Array.from(event.target.files || []); - - setUploadQueue(files.map((file) => file.name)); - - try { - const uploadPromises = files.map((file) => uploadFile(file)); - const uploadedAttachments = await Promise.all(uploadPromises); - const successfullyUploadedAttachments = uploadedAttachments.filter( - (attachment) => attachment !== undefined - ); - - setAttachments((currentAttachments) => [ - ...currentAttachments, - ...successfullyUploadedAttachments, - ]); - } catch (_error) { - toast.error("Failed to upload files"); - } finally { - setUploadQueue([]); - } - }, - [setAttachments, uploadFile] - ); - - const handlePaste = useCallback( - async (event: ClipboardEvent) => { - const items = event.clipboardData?.items; - if (!items) { - return; - } - - const imageItems = Array.from(items).filter((item) => - item.type.startsWith("image/") - ); - - if (imageItems.length === 0) { - return; - } - - event.preventDefault(); - - setUploadQueue((prev) => [...prev, "Pasted image"]); - - try { - const uploadPromises = imageItems - .map((item) => item.getAsFile()) - .filter((file): file is File => file !== null) - .map((file) => uploadFile(file)); - - const uploadedAttachments = await Promise.all(uploadPromises); - const successfullyUploadedAttachments = uploadedAttachments.filter( - (attachment) => - attachment !== undefined && - attachment.url !== undefined && - attachment.contentType !== undefined - ); - - setAttachments((curr) => [ - ...curr, - ...(successfullyUploadedAttachments as Attachment[]), - ]); - } catch (_error) { - toast.error("Failed to upload pasted image(s)"); - } finally { - setUploadQueue([]); - } - }, - [setAttachments, uploadFile] - ); - - useEffect(() => { - const textarea = textareaRef.current; - if (!textarea) { - return; - } - - textarea.addEventListener("paste", handlePaste); - return () => textarea.removeEventListener("paste", handlePaste); - }, [handlePaste]); - return (
{editingMessage && onCancelEdit && ( @@ -408,12 +488,12 @@ function PureMultimodalInput({ />
- {slashOpen && ( + {slashState.open && ( setSlashOpen(false)} + onClose={() => setSlashState((s) => ({ ...s, open: false }))} onSelect={handleSlashSelect} - query={slashQuery} - selectedIndex={slashIndex} + query={slashState.query} + selectedIndex={slashState.index} /> )}
@@ -477,33 +557,7 @@ function PureMultimodalInput({ data-testid="multimodal-input" onChange={handleInput} onKeyDown={(e) => { - if (slashOpen) { - const filtered = slashCommands.filter((cmd) => - cmd.name.startsWith(slashQuery.toLowerCase()) - ); - if (e.key === "ArrowDown") { - e.preventDefault(); - setSlashIndex((i) => Math.min(i + 1, filtered.length - 1)); - return; - } - if (e.key === "ArrowUp") { - e.preventDefault(); - setSlashIndex((i) => Math.max(i - 1, 0)); - return; - } - if (e.key === "Enter" || e.key === "Tab") { - e.preventDefault(); - if (filtered[slashIndex]) { - handleSlashSelect(filtered[slashIndex]); - } - return; - } - if (e.key === "Escape") { - e.preventDefault(); - setSlashOpen(false); - return; - } - } + if (handleSlashKeyDown(e)) return; if (e.key === "Escape" && editingMessage && onCancelEdit) { e.preventDefault(); onCancelEdit(); diff --git a/components/chat/sheet-editor.tsx b/components/chat/sheet-editor.tsx index 6830acf893..7f0872e1c9 100644 --- a/components/chat/sheet-editor.tsx +++ b/components/chat/sheet-editor.tsx @@ -2,7 +2,7 @@ import { useTheme } from "next-themes"; import { parse, unparse } from "papaparse"; -import { memo, useEffect, useMemo, useState } from "react"; +import { memo, useMemo, useState } from "react"; import DataGrid, { textEditor } from "react-data-grid"; import { cn } from "@/lib/utils"; @@ -87,10 +87,12 @@ const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => { }, [parseData, columns]); const [localRows, setLocalRows] = useState(initialRows); + const [prevInitialRows, setPrevInitialRows] = useState(initialRows); - useEffect(() => { + if (prevInitialRows !== initialRows) { + setPrevInitialRows(initialRows); setLocalRows(initialRows); - }, [initialRows]); + } const generateCsv = (data: string[][]) => { return unparse(data); diff --git a/components/chat/shell.tsx b/components/chat/shell.tsx index 9327206e82..fc7134046a 100644 --- a/components/chat/shell.tsx +++ b/components/chat/shell.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { AlertDialog, AlertDialogAction, @@ -55,19 +55,14 @@ export function ChatShell() { const isArtifactVisible = useArtifactSelector((state) => state.isVisible); const { setArtifact } = useArtifact(); - const stopRef = useRef(stop); - stopRef.current = stop; - - const prevChatIdRef = useRef(chatId); - useEffect(() => { - if (prevChatIdRef.current !== chatId) { - prevChatIdRef.current = chatId; - stopRef.current(); - setArtifact(initialArtifactData); - setEditingMessage(null); - setAttachments([]); - } - }, [chatId, setArtifact]); + const [prevChatId, setPrevChatId] = useState(chatId); + if (prevChatId !== chatId) { + setPrevChatId(chatId); + stop(); + setArtifact(initialArtifactData); + setEditingMessage(null); + setAttachments([]); + } return ( <> diff --git a/components/chat/sidebar-history.tsx b/components/chat/sidebar-history.tsx index fa56db15d5..e6969272f3 100644 --- a/components/chat/sidebar-history.tsx +++ b/components/chat/sidebar-history.tsx @@ -1,7 +1,7 @@ "use client"; import { isToday, isYesterday, subMonths, subWeeks } from "date-fns"; -import { motion } from "framer-motion"; +import { m } from "framer-motion"; import { usePathname, useRouter } from "next/navigation"; import type { User } from "next-auth"; import { useState } from "react"; @@ -332,7 +332,7 @@ export function SidebarHistory({ user }: { user: User | undefined }) { })()} - { if (!isValidating && !hasReachedEnd) { setSize((size) => size + 1); diff --git a/components/chat/suggested-actions.tsx b/components/chat/suggested-actions.tsx index 5da7c29e9e..e092d2d726 100644 --- a/components/chat/suggested-actions.tsx +++ b/components/chat/suggested-actions.tsx @@ -1,7 +1,7 @@ "use client"; import type { UseChatHelpers } from "@ai-sdk/react"; -import { motion } from "framer-motion"; +import { m } from "framer-motion"; import { memo } from "react"; import { suggestions } from "@/lib/constants"; import type { ChatMessage } from "@/lib/types"; @@ -28,7 +28,7 @@ function PureSuggestedActions({ chatId, sendMessage }: SuggestedActionsProps) { }} > {suggestedActions.map((suggestedAction, index) => ( - {suggestedAction} - + ))}
); diff --git a/components/chat/suggestion.tsx b/components/chat/suggestion.tsx index 08e1055375..069014a430 100644 --- a/components/chat/suggestion.tsx +++ b/components/chat/suggestion.tsx @@ -1,6 +1,6 @@ "use client"; -import { AnimatePresence, motion } from "framer-motion"; +import { AnimatePresence, m } from "framer-motion"; import type { UISuggestion } from "@/lib/editor/suggestions"; import { Button } from "../ui/button"; @@ -29,7 +29,7 @@ export const SuggestionDialog = ({ }} role="presentation" /> -
- +
); diff --git a/components/chat/text-editor.tsx b/components/chat/text-editor.tsx index b2af5991b1..f0229c0e35 100644 --- a/components/chat/text-editor.tsx +++ b/components/chat/text-editor.tsx @@ -49,8 +49,16 @@ function PureEditor({ const [activeSuggestion, setActiveSuggestion] = useState( null ); + const [portalTarget, setPortalTarget] = useState(null); const suggestionsRef = useRef([]); + useEffect(() => { + if (containerRef.current) { + const target = containerRef.current.closest("[data-slot='artifact-content']") as HTMLElement | null; + setPortalTarget(target); + } + }, []); + useEffect(() => { if (containerRef.current && !editorRef.current) { const state = EditorState.create({ @@ -213,16 +221,14 @@ function PureEditor({ ref={containerRef} /> {activeSuggestion && - containerRef.current?.closest("[data-slot='artifact-content']") && + portalTarget && createPortal( setActiveSuggestion(null)} suggestion={activeSuggestion} />, - containerRef.current.closest( - "[data-slot='artifact-content']" - ) as HTMLElement + portalTarget )} ); diff --git a/components/chat/toolbar.tsx b/components/chat/toolbar.tsx index a136e79739..c6a83fb837 100644 --- a/components/chat/toolbar.tsx +++ b/components/chat/toolbar.tsx @@ -1,7 +1,7 @@ "use client"; import type { UseChatHelpers } from "@ai-sdk/react"; import cx from "classnames"; -import { motion, useMotionValue, useTransform } from "framer-motion"; +import { m, useMotionValue, useTransform } from "framer-motion"; import { WrenchIcon, XIcon } from "lucide-react"; import { nanoid } from "nanoid"; import { @@ -52,13 +52,7 @@ const Tool = ({ sendMessage, onClick, }: ToolProps) => { - const [isHovered, setIsHovered] = useState(false); - - useEffect(() => { - if (selectedTool !== description) { - setIsHovered(false); - } - }, [selectedTool, description]); + const isSelected = selectedTool === description; const handleSelect = () => { if (!isToolbarVisible && setIsToolbarVisible) { @@ -67,7 +61,6 @@ const Tool = ({ } if (!selectedTool) { - setIsHovered(true); setSelectedTool(description); return; } @@ -81,9 +74,9 @@ const Tool = ({ }; return ( - + - { handleSelect(); }} - onHoverEnd={() => { - if (selectedTool !== description) { - setIsHovered(false); - } - }} - onHoverStart={() => { - setIsHovered(true); - }} onKeyDown={(event) => { if (event.key === "Enter") { handleSelect(); @@ -114,7 +99,7 @@ const Tool = ({ whileTap={{ scale: 0.95 }} > {selectedTool === description ? : icon} - + {randomArr.map((id) => ( -
- + ))} - {currentLevel === 2 ? : } - + { return ( - ))} - + ); }; @@ -383,7 +368,7 @@ const PureToolbar = ({ return ( - {onClose && ( - - + )} {status === "streaming" ? ( - - + ) : selectedTool === "adjust-reading-level" ? ( )} - + ); }; diff --git a/components/chat/version-footer.tsx b/components/chat/version-footer.tsx index 9da560c99b..4326cb9d66 100644 --- a/components/chat/version-footer.tsx +++ b/components/chat/version-footer.tsx @@ -1,9 +1,8 @@ "use client"; import { isAfter } from "date-fns"; -import { motion } from "framer-motion"; +import { m } from "framer-motion"; import { ChevronLeftIcon, ChevronRightIcon, DiffIcon } from "lucide-react"; -import type { Dispatch, SetStateAction } from "react"; import { useState } from "react"; import { useSWRConfig } from "swr"; import { useArtifact } from "@/hooks/use-artifact"; @@ -16,7 +15,7 @@ type VersionFooterProps = { documents: Document[] | undefined; currentVersionIndex: number; mode: "edit" | "diff"; - setMode: Dispatch>; + setMode: (mode: "edit" | "diff") => void; }; export const VersionFooter = ({ @@ -39,7 +38,7 @@ export const VersionFooter = ({ const isLast = currentVersionIndex === documents.length - 1; return ( - { setIsMutating(true); + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + const docApiUrl = `${basePath}/api/document?id=${artifact.documentId}`; + const timestamp = getDocumentTimestampByIndex(documents, currentVersionIndex); + const deleteUrl = `${docApiUrl}×tamp=${timestamp}`; + const optimisticData = documents + ? documents.filter((document) => + isAfter( + new Date(document.createdAt), + new Date(timestamp) + ) + ) + : []; + try { await mutate( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}`, - await fetch( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}×tamp=${getDocumentTimestampByIndex( - documents, - currentVersionIndex - )}`, - { - method: "DELETE", - } - ), - { - optimisticData: documents - ? [ - ...documents.filter((document) => - isAfter( - new Date(document.createdAt), - new Date( - getDocumentTimestampByIndex( - documents, - currentVersionIndex - ) - ) - ) - ), - ] - : [], - } + docApiUrl, + await fetch(deleteUrl, { method: "DELETE" }), + { optimisticData } ); - } finally { - setIsMutating(false); + } catch { + // error handled by optimistic update rollback } + setIsMutating(false); }} type="button" > @@ -143,6 +132,6 @@ export const VersionFooter = ({ Latest
-
+ ); }; diff --git a/components/motion-provider.tsx b/components/motion-provider.tsx new file mode 100644 index 0000000000..8789f25fb9 --- /dev/null +++ b/components/motion-provider.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { LazyMotion, domAnimation } from "framer-motion"; +import type { ReactNode } from "react"; + +export function MotionProvider({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/components/ui/input-group.tsx b/components/ui/input-group.tsx index e4b6599a25..eae4f2d772 100644 --- a/components/ui/input-group.tsx +++ b/components/ui/input-group.tsx @@ -60,6 +60,11 @@ function InputGroupAddon({ } e.currentTarget.parentElement?.querySelector("input")?.focus() }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.currentTarget.parentElement?.querySelector("input")?.focus() + } + }} {...props} /> ) diff --git a/hooks/use-active-chat.tsx b/hooks/use-active-chat.tsx index 85082a9f36..223c8fb860 100644 --- a/hooks/use-active-chat.tsx +++ b/hooks/use-active-chat.tsx @@ -6,12 +6,12 @@ import { DefaultChatTransport } from "ai"; import { usePathname } from "next/navigation"; import { createContext, - type Dispatch, type ReactNode, - type SetStateAction, + useCallback, useContext, useEffect, useMemo, + useReducer, useRef, useState, } from "react"; @@ -38,7 +38,7 @@ type ActiveChatContextValue = { regenerate: UseChatHelpers["regenerate"]; addToolApprovalResponse: UseChatHelpers["addToolApprovalResponse"]; input: string; - setInput: Dispatch>; + setInput: (val: string) => void; visibilityType: VisibilityType; isReadonly: boolean; isLoading: boolean; @@ -46,11 +46,39 @@ type ActiveChatContextValue = { currentModelId: string; setCurrentModelId: (id: string) => void; showCreditCardAlert: boolean; - setShowCreditCardAlert: Dispatch>; + setShowCreditCardAlert: (val: boolean) => void; }; const ActiveChatContext = createContext(null); +type ChatUIState = { + currentModelId: string; + input: string; + showCreditCardAlert: boolean; + hasLoadedCookieModel: boolean; +}; + +type ChatUIAction = + | { type: "SET_MODEL"; modelId: string } + | { type: "SET_INPUT"; input: string } + | { type: "SET_CREDIT_CARD_ALERT"; show: boolean } + | { type: "SET_COOKIE_MODEL_LOADED" }; + +function chatUIReducer(state: ChatUIState, action: ChatUIAction): ChatUIState { + switch (action.type) { + case "SET_MODEL": + return { ...state, currentModelId: action.modelId }; + case "SET_INPUT": + return { ...state, input: action.input }; + case "SET_CREDIT_CARD_ALERT": + return { ...state, showCreditCardAlert: action.show }; + case "SET_COOKIE_MODEL_LOADED": + return { ...state, hasLoadedCookieModel: true }; + default: + return state; + } +} + function extractChatId(pathname: string): string | null { const match = pathname.match(/\/chat\/([^/]+)/); return match ? match[1] : null; @@ -63,24 +91,36 @@ export function ActiveChatProvider({ children }: { children: ReactNode }) { const chatIdFromUrl = extractChatId(pathname); const isNewChat = !chatIdFromUrl; - const newChatIdRef = useRef(generateUUID()); - const prevPathnameRef = useRef(pathname); + const [newChatId, setNewChatId] = useState(generateUUID); + const [prevPathname, setPrevPathname] = useState(pathname); - if (isNewChat && prevPathnameRef.current !== pathname) { - newChatIdRef.current = generateUUID(); + if (prevPathname !== pathname) { + setPrevPathname(pathname); + if (isNewChat) { + setNewChatId(generateUUID()); + } } - prevPathnameRef.current = pathname; - const chatId = chatIdFromUrl ?? newChatIdRef.current; + const chatId = chatIdFromUrl ?? newChatId; - const [currentModelId, setCurrentModelId] = useState(DEFAULT_CHAT_MODEL); - const currentModelIdRef = useRef(currentModelId); - useEffect(() => { - currentModelIdRef.current = currentModelId; - }, [currentModelId]); + const [uiState, dispatchUI] = useReducer(chatUIReducer, { + currentModelId: DEFAULT_CHAT_MODEL, + input: "", + showCreditCardAlert: false, + hasLoadedCookieModel: false, + }); + const { currentModelId, showCreditCardAlert } = uiState; + const input = uiState.input; - const [input, setInput] = useState(""); - const [showCreditCardAlert, setShowCreditCardAlert] = useState(false); + const setInput = useCallback( + (val: string) => dispatchUI({ type: "SET_INPUT", input: val }), + [] + ); + const setCurrentModelId = (id: string) => dispatchUI({ type: "SET_MODEL", modelId: id }); + const setShowCreditCardAlert = useCallback( + (val: boolean) => dispatchUI({ type: "SET_CREDIT_CARD_ALERT", show: val }), + [] + ); const { data: chatData, isLoading } = useSWR( isNewChat @@ -144,7 +184,7 @@ export function ActiveChatProvider({ children }: { children: ReactNode }) { ...(isToolApprovalContinuation ? { messages: request.messages } : { message: lastMessage }), - selectedChatModel: currentModelIdRef.current, + selectedChatModel: currentModelId, selectedVisibilityType: visibility, ...request.body, }, @@ -173,9 +213,11 @@ export function ActiveChatProvider({ children }: { children: ReactNode }) { const loadedChatIds = useRef(new Set()); - if (isNewChat && !loadedChatIds.current.has(newChatIdRef.current)) { - loadedChatIds.current.add(newChatIdRef.current); - } + useEffect(() => { + if (isNewChat && !loadedChatIds.current.has(chatId)) { + loadedChatIds.current.add(chatId); + } + }, [isNewChat, chatId]); useEffect(() => { if (loadedChatIds.current.has(chatId)) { @@ -197,8 +239,9 @@ export function ActiveChatProvider({ children }: { children: ReactNode }) { } }, [chatId, isNewChat, setMessages]); - useEffect(() => { - if (chatData && !isNewChat) { + if (chatData && !isNewChat && !uiState.hasLoadedCookieModel) { + dispatchUI({ type: "SET_COOKIE_MODEL_LOADED" }); + if (typeof document !== "undefined") { const cookieModel = document.cookie .split("; ") .find((row) => row.startsWith("chat-model=")) @@ -207,7 +250,7 @@ export function ActiveChatProvider({ children }: { children: ReactNode }) { setCurrentModelId(decodeURIComponent(cookieModel)); } } - }, [chatData, isNewChat]); + } const hasAppendedQueryRef = useRef(false); useEffect(() => { @@ -275,6 +318,7 @@ export function ActiveChatProvider({ children }: { children: ReactNode }) { regenerate, addToolApprovalResponse, input, + setInput, visibility, isReadonly, isNewChat, @@ -282,6 +326,7 @@ export function ActiveChatProvider({ children }: { children: ReactNode }) { votes, currentModelId, showCreditCardAlert, + setShowCreditCardAlert, ] ); diff --git a/hooks/use-messages.tsx b/hooks/use-messages.tsx index 6905194603..31da2b468a 100644 --- a/hooks/use-messages.tsx +++ b/hooks/use-messages.tsx @@ -1,5 +1,5 @@ import type { UseChatHelpers } from "@ai-sdk/react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import type { ChatMessage } from "@/lib/types"; import { useScrollToBottom } from "./use-scroll-to-bottom"; @@ -20,11 +20,9 @@ export function useMessages({ const [hasSentMessage, setHasSentMessage] = useState(false); - useEffect(() => { - if (status === "submitted") { - setHasSentMessage(true); - } - }, [status]); + if (status === "submitted" && !hasSentMessage) { + setHasSentMessage(true); + } return { containerRef, diff --git a/hooks/use-reduced-motion.ts b/hooks/use-reduced-motion.ts new file mode 100644 index 0000000000..69cad908a2 --- /dev/null +++ b/hooks/use-reduced-motion.ts @@ -0,0 +1,21 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export function useReducedMotion(): boolean { + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); + + useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); + setPrefersReducedMotion(mediaQuery.matches); + + const handler = (event: MediaQueryListEvent) => { + setPrefersReducedMotion(event.matches); + }; + + mediaQuery.addEventListener("change", handler); + return () => mediaQuery.removeEventListener("change", handler); + }, []); + + return prefersReducedMotion; +}