From b1009d3b1f54198ec7f43ac2a58f66fc7874de3f Mon Sep 17 00:00:00 2001 From: Borja <158476064+borja-writefull@users.noreply.github.com> Date: Tue, 18 Nov 2025 10:24:40 +0100 Subject: [PATCH] Writefull refactor bootstrapping (#29549) GitOrigin-RevId: f8422d3c3d1d7e588f18352e08c7596b520225e5 --- libraries/ai/components.json | 21 - libraries/ai/components/ai-elements/LICENSE | 13 - libraries/ai/components/ai-elements/README.md | 1 - .../components/ai-elements/conversation.tsx | 97 -- .../ai/components/ai-elements/message.tsx | 58 - .../components/ai-elements/prompt-input.tsx | 1272 ----------------- .../ai/components/ai-elements/reasoning.tsx | 178 --- .../ai/components/ai-elements/response.tsx | 22 - .../ai/components/ai-elements/shimmer.tsx | 64 - libraries/ai/components/chat-prompt-input.tsx | 51 - libraries/ai/components/chat.tsx | 187 --- .../ai/components/shadow-root-portal.tsx | 32 - libraries/ai/components/ui/LICENSE.md | 21 - libraries/ai/components/ui/README.md | 1 - libraries/ai/components/ui/alert.tsx | 66 - libraries/ai/components/ui/button.tsx | 56 - libraries/ai/components/ui/collapsible.tsx | 9 - libraries/ai/components/ui/dropdown-menu.tsx | 200 --- libraries/ai/components/ui/hover-card.tsx | 27 - libraries/ai/components/ui/input-group.tsx | 168 --- libraries/ai/components/ui/input.tsx | 22 - libraries/ai/components/ui/select.tsx | 158 -- libraries/ai/components/ui/skeleton.tsx | 14 - libraries/ai/components/ui/textarea.tsx | 22 - libraries/ai/index.ts | 10 - libraries/ai/package.json | 37 - libraries/ai/postcss.config.js | 9 - libraries/ai/tailwind.config.js | 85 -- libraries/ai/tailwind.css | 157 -- libraries/ai/tsconfig.json | 12 - libraries/ai/utils/index.ts | 6 - services/web/config/settings.defaults.js | 2 + .../ide-react/context/react-context-root.tsx | 24 +- .../components/codemirror-toolbar.tsx | 9 + .../context/types/writefull-instance.ts | 1 - 35 files changed, 33 insertions(+), 3079 deletions(-) delete mode 100644 libraries/ai/components.json delete mode 100644 libraries/ai/components/ai-elements/LICENSE delete mode 100644 libraries/ai/components/ai-elements/README.md delete mode 100644 libraries/ai/components/ai-elements/conversation.tsx delete mode 100644 libraries/ai/components/ai-elements/message.tsx delete mode 100644 libraries/ai/components/ai-elements/prompt-input.tsx delete mode 100644 libraries/ai/components/ai-elements/reasoning.tsx delete mode 100644 libraries/ai/components/ai-elements/response.tsx delete mode 100644 libraries/ai/components/ai-elements/shimmer.tsx delete mode 100644 libraries/ai/components/chat-prompt-input.tsx delete mode 100644 libraries/ai/components/chat.tsx delete mode 100644 libraries/ai/components/shadow-root-portal.tsx delete mode 100644 libraries/ai/components/ui/LICENSE.md delete mode 100644 libraries/ai/components/ui/README.md delete mode 100644 libraries/ai/components/ui/alert.tsx delete mode 100644 libraries/ai/components/ui/button.tsx delete mode 100644 libraries/ai/components/ui/collapsible.tsx delete mode 100644 libraries/ai/components/ui/dropdown-menu.tsx delete mode 100644 libraries/ai/components/ui/hover-card.tsx delete mode 100644 libraries/ai/components/ui/input-group.tsx delete mode 100644 libraries/ai/components/ui/input.tsx delete mode 100644 libraries/ai/components/ui/select.tsx delete mode 100644 libraries/ai/components/ui/skeleton.tsx delete mode 100644 libraries/ai/components/ui/textarea.tsx delete mode 100644 libraries/ai/index.ts delete mode 100644 libraries/ai/package.json delete mode 100644 libraries/ai/postcss.config.js delete mode 100644 libraries/ai/tailwind.config.js delete mode 100644 libraries/ai/tailwind.css delete mode 100644 libraries/ai/tsconfig.json delete mode 100644 libraries/ai/utils/index.ts diff --git a/libraries/ai/components.json b/libraries/ai/components.json deleted file mode 100644 index 2243165834..0000000000 --- a/libraries/ai/components.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "default", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "tailwind.config.js", - "css": "tailwind.css", - "baseColor": "slate", - "cssVariables": true - }, - "iconLibrary": "lucide", - "aliases": { - "components": "@overleaf/ai/components", - "utils": "@overleaf/ai/utils", - "ui": "@overleaf/ai/components/ui" - }, - "registries": { - "@ai-elements": "https://registry.ai-sdk.dev/{name}.json" - } -} diff --git a/libraries/ai/components/ai-elements/LICENSE b/libraries/ai/components/ai-elements/LICENSE deleted file mode 100644 index 824d33fd91..0000000000 --- a/libraries/ai/components/ai-elements/LICENSE +++ /dev/null @@ -1,13 +0,0 @@ -Copyright 2023 Vercel, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/libraries/ai/components/ai-elements/README.md b/libraries/ai/components/ai-elements/README.md deleted file mode 100644 index 0c28b68724..0000000000 --- a/libraries/ai/components/ai-elements/README.md +++ /dev/null @@ -1 +0,0 @@ -AI Elements components are derived from https://github.com/vercel/ai-elements diff --git a/libraries/ai/components/ai-elements/conversation.tsx b/libraries/ai/components/ai-elements/conversation.tsx deleted file mode 100644 index fd944cda76..0000000000 --- a/libraries/ai/components/ai-elements/conversation.tsx +++ /dev/null @@ -1,97 +0,0 @@ -'use client' - -import { Button } from '../ui/button' -import { cn } from '../../utils' -import { ArrowDownIcon } from 'lucide-react' -import type { ComponentProps } from 'react' -import React, { useCallback } from 'react' -import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom' - -export type ConversationProps = ComponentProps - -export const Conversation = ({ className, ...props }: ConversationProps) => ( - -) - -export type ConversationContentProps = ComponentProps< - typeof StickToBottom.Content -> - -export const ConversationContent = ({ - className, - ...props -}: ConversationContentProps) => ( - -) - -export type ConversationEmptyStateProps = ComponentProps<'div'> & { - title?: string - description?: string - icon?: React.ReactNode -} - -export const ConversationEmptyState = ({ - className, - title = 'No messages yet', - description = 'Start a conversation to see messages here', - icon, - children, - ...props -}: ConversationEmptyStateProps) => ( -
- {children ?? ( - <> - {icon &&
{icon}
} -
-

{title}

- {description && ( -

{description}

- )} -
- - )} -
-) - -export type ConversationScrollButtonProps = ComponentProps - -export const ConversationScrollButton = ({ - className, - ...props -}: ConversationScrollButtonProps) => { - const { isAtBottom, scrollToBottom } = useStickToBottomContext() - - const handleScrollToBottom = useCallback(() => { - scrollToBottom() - }, [scrollToBottom]) - - return ( - !isAtBottom && ( - - ) - ) -} diff --git a/libraries/ai/components/ai-elements/message.tsx b/libraries/ai/components/ai-elements/message.tsx deleted file mode 100644 index b33442c0ec..0000000000 --- a/libraries/ai/components/ai-elements/message.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { cn } from '../../utils' -import type { UIMessage } from 'ai' -import { cva, type VariantProps } from 'class-variance-authority' -import type { HTMLAttributes } from 'react' - -export type MessageProps = HTMLAttributes & { - from: UIMessage['role'] -} - -export const Message = ({ className, from, ...props }: MessageProps) => ( -
-) - -const messageContentVariants = cva( - 'is-user:dark flex flex-col gap-2 overflow-hidden rounded-lg text-sm', - { - variants: { - variant: { - contained: [ - 'max-w-[80%] px-4 py-3', - 'group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground', - 'group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground', - ], - flat: [ - 'group-[.is-user]:max-w-[80%] group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground', - 'group-[.is-assistant]:text-foreground', - ], - }, - }, - defaultVariants: { - variant: 'contained', - }, - } -) - -export type MessageContentProps = HTMLAttributes & - VariantProps - -export const MessageContent = ({ - children, - className, - variant, - ...props -}: MessageContentProps) => ( -
- {children} -
-) diff --git a/libraries/ai/components/ai-elements/prompt-input.tsx b/libraries/ai/components/ai-elements/prompt-input.tsx deleted file mode 100644 index 93a679ba68..0000000000 --- a/libraries/ai/components/ai-elements/prompt-input.tsx +++ /dev/null @@ -1,1272 +0,0 @@ -'use client' - -import { Button } from '../ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '../ui/dropdown-menu' -import { HoverCard, HoverCardContent, HoverCardTrigger } from '../ui/hover-card' -import { - InputGroup, - InputGroupAddon, - InputGroupButton, - InputGroupTextarea, -} from '../ui/input-group' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '../ui/select' -import { cn } from '../../utils' -import type { ChatStatus, FileUIPart } from 'ai' -import { - ImageIcon, - Loader2Icon, - MicIcon, - PaperclipIcon, - PlusIcon, - SendIcon, - SquareIcon, - XIcon, -} from 'lucide-react' -import { nanoid } from 'nanoid' -import { - type ChangeEvent, - type ChangeEventHandler, - Children, - type ClipboardEventHandler, - type ComponentProps, - createContext, - type FormEvent, - type FormEventHandler, - Fragment, - type HTMLAttributes, - type KeyboardEventHandler, - type PropsWithChildren, - type ReactNode, - type RefObject, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from 'react' -// ============================================================================ -// Provider Context & Types -// ============================================================================ - -export type AttachmentsContext = { - files: (FileUIPart & { id: string })[] - add: (files: File[] | FileList) => void - remove: (id: string) => void - clear: () => void - openFileDialog: () => void - fileInputRef: RefObject -} - -export type TextInputContext = { - value: string - setInput: (v: string) => void - clear: () => void -} - -export type PromptInputControllerProps = { - textInput: TextInputContext - attachments: AttachmentsContext - /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ - __registerFileInput: ( - ref: RefObject, - open: () => void - ) => void -} - -const PromptInputController = createContext( - null -) -const ProviderAttachmentsContext = createContext( - null -) - -export const usePromptInputController = () => { - const ctx = useContext(PromptInputController) - if (!ctx) { - throw new Error( - 'Wrap your component inside to use usePromptInputController().' - ) - } - return ctx -} - -// Optional variants (do NOT throw). Useful for dual-mode components. -const useOptionalPromptInputController = () => useContext(PromptInputController) - -export const useProviderAttachments = () => { - const ctx = useContext(ProviderAttachmentsContext) - if (!ctx) { - throw new Error( - 'Wrap your component inside to use useProviderAttachments().' - ) - } - return ctx -} - -const useOptionalProviderAttachments = () => - useContext(ProviderAttachmentsContext) - -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. - */ -export function PromptInputProvider({ - initialInput: initialTextInput = '', - children, -}: PromptInputProviderProps) { - // ----- textInput state - const [textInput, setTextInput] = useState(initialTextInput) - const clearInput = useCallback(() => setTextInput(''), []) - - // ----- attachments state (global when wrapped) - const [attachements, setAttachements] = useState< - (FileUIPart & { id: string })[] - >([]) - const fileInputRef = useRef(null) - const openRef = useRef<() => void>(() => {}) - - const add = useCallback((files: File[] | FileList) => { - const incoming = Array.from(files) - if (incoming.length === 0) return - - setAttachements(prev => - prev.concat( - incoming.map(file => ({ - id: nanoid(), - type: 'file' as const, - url: URL.createObjectURL(file), - mediaType: file.type, - filename: file.name, - })) - ) - ) - }, []) - - const remove = useCallback((id: string) => { - setAttachements(prev => { - const found = prev.find(f => f.id === id) - if (found?.url) URL.revokeObjectURL(found.url) - return prev.filter(f => f.id !== id) - }) - }, []) - - const clear = useCallback(() => { - setAttachements(prev => { - for (const f of prev) if (f.url) URL.revokeObjectURL(f.url) - return [] - }) - }, []) - - const openFileDialog = useCallback(() => { - openRef.current?.() - }, []) - - const attachments = useMemo( - () => ({ - files: attachements, - add, - remove, - clear, - openFileDialog, - fileInputRef, - }), - [attachements, add, remove, clear, openFileDialog] - ) - - const __registerFileInput = useCallback( - (ref: RefObject, open: () => void) => { - fileInputRef.current = ref.current - openRef.current = open - }, - [] - ) - - const controller = useMemo( - () => ({ - textInput: { - value: textInput, - setInput: setTextInput, - clear: clearInput, - }, - attachments, - __registerFileInput, - }), - [textInput, clearInput, attachments, __registerFileInput] - ) - - return ( - - - {children} - - - ) -} - -// ============================================================================ -// Component Context & Hooks -// ============================================================================ - -const LocalAttachmentsContext = createContext(null) - -export const usePromptInputAttachments = () => { - // Dual-mode: prefer provider if present, otherwise use local - const provider = useOptionalProviderAttachments() - const local = useContext(LocalAttachmentsContext) - const context = provider ?? local - if (!context) { - throw new Error( - 'usePromptInputAttachments must be used within a PromptInput or PromptInputProvider' - ) - } - return context -} - -export type PromptInputAttachmentProps = HTMLAttributes & { - data: FileUIPart & { id: string } - className?: string -} - -export function PromptInputAttachment({ - data, - className, - ...props -}: PromptInputAttachmentProps) { - const attachments = usePromptInputAttachments() - - const filename = data.filename || '' - - const mediaType = - data.mediaType?.startsWith('image/') && data.url ? 'image' : 'file' - const isImage = mediaType === 'image' - - const attachmentLabel = filename || (isImage ? 'Image' : 'Attachment') - - return ( - - -
-
-
- {isImage ? ( - {filename - ) : ( -
- -
- )} -
- -
- - {attachmentLabel} -
-
- -
- {isImage && ( -
- {filename -
- )} -
-
-

- {filename || (isImage ? 'Image' : 'Attachment')} -

- {data.mediaType && ( -

- {data.mediaType} -

- )} -
-
-
-
-
- ) -} - -export type PromptInputAttachmentsProps = Omit< - HTMLAttributes, - 'children' -> & { - children: (attachment: FileUIPart & { id: string }) => ReactNode -} - -export function PromptInputAttachments({ - children, -}: PromptInputAttachmentsProps) { - const attachments = usePromptInputAttachments() - - if (!attachments.files.length) { - return null - } - - return attachments.files.map(file => ( - {children(file)} - )) -} - -export type PromptInputActionAddAttachmentsProps = ComponentProps< - typeof DropdownMenuItem -> & { - label?: string -} - -export const PromptInputActionAddAttachments = ({ - label = 'Add photos or files', - ...props -}: PromptInputActionAddAttachmentsProps) => { - const attachments = usePromptInputAttachments() - - return ( - { - e.preventDefault() - attachments.openFileDialog() - }} - > - {label} - - ) -} - -export type PromptInputMessage = { - text?: string - files?: FileUIPart[] -} - -export type PromptInputProps = Omit< - HTMLAttributes, - 'onSubmit' | 'onError' -> & { - accept?: string // e.g., "image/*" or leave undefined for any - 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 - maxFileSize?: number // bytes - onError?: (err: { - code: 'max_files' | 'max_file_size' | 'accept' - message: string - }) => void - onSubmit: ( - message: PromptInputMessage, - event: FormEvent - ) => void | Promise -} - -export const PromptInput = ({ - className, - 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 - const inputRef = useRef(null) - const anchorRef = useRef(null) - const formRef = useRef(null) - - // Find nearest form to scope drag & drop - useEffect(() => { - const root = anchorRef.current?.closest('form') - if (root instanceof HTMLFormElement) { - formRef.current = root - } - }, []) - - // ----- Local attachments (only used when no provider) - const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]) - const files = usingProvider ? controller.attachments.files : items - - const openFileDialogLocal = useCallback(() => { - inputRef.current?.click() - }, []) - - const matchesAccept = useCallback( - (f: File) => { - if (!accept || accept.trim() === '') { - return true - } - if (accept.includes('image/*')) { - return f.type.startsWith('image/') - } - // NOTE: keep simple; expand as needed - return true - }, - [accept] - ) - - const addLocal = useCallback( - (fileList: File[] | FileList) => { - const incoming = Array.from(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 - } - - 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({ - id: nanoid(), - type: 'file', - url: URL.createObjectURL(file), - mediaType: file.type, - filename: file.name, - }) - } - return prev.concat(next) - }) - }, - [matchesAccept, maxFiles, maxFileSize, onError] - ) - - const add = usingProvider - ? (files: File[] | FileList) => controller.attachments.add(files) - : addLocal - - const remove = usingProvider - ? (id: string) => controller.attachments.remove(id) - : (id: string) => - setItems(prev => { - const found = prev.find(file => file.id === id) - if (found?.url) { - URL.revokeObjectURL(found.url) - } - return prev.filter(file => file.id !== id) - }) - - const clear = usingProvider - ? () => controller.attachments.clear() - : () => - setItems(prev => { - for (const file of prev) { - if (file.url) { - URL.revokeObjectURL(file.url) - } - } - return [] - }) - - const openFileDialog = usingProvider - ? () => controller.attachments.openFileDialog() - : openFileDialogLocal - - // 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]) - - // 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]) - - // Attach drop handlers on nearest form and document (opt-in) - useEffect(() => { - const form = formRef.current - if (!form) return - - const onDragOver = (e: DragEvent) => { - 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) - } - } - form.addEventListener('dragover', onDragOver) - form.addEventListener('drop', onDrop) - return () => { - form.removeEventListener('dragover', onDragOver) - form.removeEventListener('drop', onDrop) - } - }, [add]) - - useEffect(() => { - if (!globalDrop) return - - const onDragOver = (e: DragEvent) => { - 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) - } - } - document.addEventListener('dragover', onDragOver) - document.addEventListener('drop', onDrop) - return () => { - document.removeEventListener('dragover', onDragOver) - document.removeEventListener('drop', onDrop) - } - }, [add, globalDrop]) - - useEffect( - () => () => { - if (!usingProvider) { - for (const f of files) { - if (f.url) URL.revokeObjectURL(f.url) - } - } - }, - [usingProvider, files] - ) - - const handleChange: ChangeEventHandler = event => { - if (event.currentTarget.files) { - add(event.currentTarget.files) - } - } - - const convertBlobUrlToDataUrl = async (url: string): Promise => { - const response = await fetch(url) - const blob = await response.blob() - return await new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onloadend = () => resolve(reader.result as string) - reader.onerror = reject - reader.readAsDataURL(blob) - }) - } - - const ctx = useMemo( - () => ({ - files: files.map(item => ({ ...item, id: item.id })), - add, - remove, - clear, - openFileDialog, - fileInputRef: inputRef, - }), - [files, add, remove, clear, openFileDialog] - ) - - const handleSubmit: FormEventHandler = event => { - event.preventDefault() - - const form = event.currentTarget - const text = usingProvider - ? controller.textInput.value - : (() => { - const formData = new FormData(form) - 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() - } - - // Convert blob URLs to data URLs asynchronously - Promise.all( - files.map(async ({ id, ...item }) => { - if (item.url && item.url.startsWith('blob:')) { - return { - ...item, - url: await convertBlobUrlToDataUrl(item.url), - } - } - return item - }) - ).then((convertedFiles: FileUIPart[]) => { - try { - const result = onSubmit({ text, files: convertedFiles }, event) - - // Handle both sync and async onSubmit - if (result instanceof Promise) { - result - .then(() => { - clear() - if (usingProvider) { - controller.textInput.clear() - } - }) - .catch(() => { - // Don't clear on error - user may want to retry - }) - } else { - // Sync function completed without throwing, clear attachments - clear() - if (usingProvider) { - controller.textInput.clear() - } - } - } catch (error) { - // Don't clear on error - user may want to retry - } - }) - } - - // Render with or without local provider - const inner = ( - <> -