From 8024fe2c583e9ca2a2d6a5ef901973f059a85bc6 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Mon, 10 Nov 2025 12:21:32 +0000 Subject: [PATCH] [web] Add AI workbench to alpha users (#29417) Co-authored-by: Alf Eaton GitOrigin-RevId: 79bb329932b1e6fcc88f648bca9cc4bee215cd41 --- 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 + package-lock.json | 5427 ++++++++++++++++- services/web/.jenkinsIncludeFile | 1 + services/web/Dockerfile | 3 + services/web/Makefile | 1 + .../Features/Project/ProjectController.mjs | 1 + .../web/app/src/models/UserFeatureUsage.js | 1 + services/web/config/settings.defaults.js | 1 + .../web/frontend/extracted-translations.json | 1 + ...alSymbolsRoundedUnfilledPartialSlice.woff2 | Bin 7740 -> 8148 bytes .../material-symbols/unfilled-symbols.mjs | 1 + .../components/rail/rail-panel.tsx | 4 +- .../ide-redesign/components/rail/rail.tsx | 25 +- .../ide-redesign/contexts/rail-context.tsx | 1 + .../features/ide-redesign/utils/rail-types.ts | 2 +- services/web/locales/en.json | 1 + services/web/package.json | 4 + services/web/tsconfig.json | 1 + services/web/webpack.config.js | 22 + 49 files changed, 8446 insertions(+), 127 deletions(-) create mode 100644 libraries/ai/components.json create mode 100644 libraries/ai/components/ai-elements/LICENSE create mode 100644 libraries/ai/components/ai-elements/README.md create mode 100644 libraries/ai/components/ai-elements/conversation.tsx create mode 100644 libraries/ai/components/ai-elements/message.tsx create mode 100644 libraries/ai/components/ai-elements/prompt-input.tsx create mode 100644 libraries/ai/components/ai-elements/reasoning.tsx create mode 100644 libraries/ai/components/ai-elements/response.tsx create mode 100644 libraries/ai/components/ai-elements/shimmer.tsx create mode 100644 libraries/ai/components/chat-prompt-input.tsx create mode 100644 libraries/ai/components/chat.tsx create mode 100644 libraries/ai/components/shadow-root-portal.tsx create mode 100644 libraries/ai/components/ui/LICENSE.md create mode 100644 libraries/ai/components/ui/README.md create mode 100644 libraries/ai/components/ui/alert.tsx create mode 100644 libraries/ai/components/ui/button.tsx create mode 100644 libraries/ai/components/ui/collapsible.tsx create mode 100644 libraries/ai/components/ui/dropdown-menu.tsx create mode 100644 libraries/ai/components/ui/hover-card.tsx create mode 100644 libraries/ai/components/ui/input-group.tsx create mode 100644 libraries/ai/components/ui/input.tsx create mode 100644 libraries/ai/components/ui/select.tsx create mode 100644 libraries/ai/components/ui/skeleton.tsx create mode 100644 libraries/ai/components/ui/textarea.tsx create mode 100644 libraries/ai/index.ts create mode 100644 libraries/ai/package.json create mode 100644 libraries/ai/postcss.config.js create mode 100644 libraries/ai/tailwind.config.js create mode 100644 libraries/ai/tailwind.css create mode 100644 libraries/ai/tsconfig.json create mode 100644 libraries/ai/utils/index.ts diff --git a/libraries/ai/components.json b/libraries/ai/components.json new file mode 100644 index 0000000000..2243165834 --- /dev/null +++ b/libraries/ai/components.json @@ -0,0 +1,21 @@ +{ + "$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 new file mode 100644 index 0000000000..824d33fd91 --- /dev/null +++ b/libraries/ai/components/ai-elements/LICENSE @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000000..0c28b68724 --- /dev/null +++ b/libraries/ai/components/ai-elements/README.md @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000000..fd944cda76 --- /dev/null +++ b/libraries/ai/components/ai-elements/conversation.tsx @@ -0,0 +1,97 @@ +'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 new file mode 100644 index 0000000000..b33442c0ec --- /dev/null +++ b/libraries/ai/components/ai-elements/message.tsx @@ -0,0 +1,58 @@ +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 new file mode 100644 index 0000000000..93a679ba68 --- /dev/null +++ b/libraries/ai/components/ai-elements/prompt-input.tsx @@ -0,0 +1,1272 @@ +'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 = ( + <> +