Ver código fonte

[web] Add AI workbench to alpha users (#29417)

Co-authored-by: Alf Eaton <alf.eaton@overleaf.com>
GitOrigin-RevId: 79bb329932b1e6fcc88f648bca9cc4bee215cd41
Mathias Jakobsen 9 meses atrás
pai
commit
8024fe2c58
49 arquivos alterados com 4823 adições e 23 exclusões
  1. 21 0
      libraries/ai/components.json
  2. 13 0
      libraries/ai/components/ai-elements/LICENSE
  3. 1 0
      libraries/ai/components/ai-elements/README.md
  4. 97 0
      libraries/ai/components/ai-elements/conversation.tsx
  5. 58 0
      libraries/ai/components/ai-elements/message.tsx
  6. 1272 0
      libraries/ai/components/ai-elements/prompt-input.tsx
  7. 178 0
      libraries/ai/components/ai-elements/reasoning.tsx
  8. 22 0
      libraries/ai/components/ai-elements/response.tsx
  9. 64 0
      libraries/ai/components/ai-elements/shimmer.tsx
  10. 51 0
      libraries/ai/components/chat-prompt-input.tsx
  11. 187 0
      libraries/ai/components/chat.tsx
  12. 32 0
      libraries/ai/components/shadow-root-portal.tsx
  13. 21 0
      libraries/ai/components/ui/LICENSE.md
  14. 1 0
      libraries/ai/components/ui/README.md
  15. 66 0
      libraries/ai/components/ui/alert.tsx
  16. 56 0
      libraries/ai/components/ui/button.tsx
  17. 9 0
      libraries/ai/components/ui/collapsible.tsx
  18. 200 0
      libraries/ai/components/ui/dropdown-menu.tsx
  19. 27 0
      libraries/ai/components/ui/hover-card.tsx
  20. 168 0
      libraries/ai/components/ui/input-group.tsx
  21. 22 0
      libraries/ai/components/ui/input.tsx
  22. 158 0
      libraries/ai/components/ui/select.tsx
  23. 14 0
      libraries/ai/components/ui/skeleton.tsx
  24. 22 0
      libraries/ai/components/ui/textarea.tsx
  25. 10 0
      libraries/ai/index.ts
  26. 37 0
      libraries/ai/package.json
  27. 9 0
      libraries/ai/postcss.config.js
  28. 85 0
      libraries/ai/tailwind.config.js
  29. 157 0
      libraries/ai/tailwind.css
  30. 12 0
      libraries/ai/tsconfig.json
  31. 6 0
      libraries/ai/utils/index.ts
  32. 1682 18
      package-lock.json
  33. 1 0
      services/web/.jenkinsIncludeFile
  34. 3 0
      services/web/Dockerfile
  35. 1 0
      services/web/Makefile
  36. 1 0
      services/web/app/src/Features/Project/ProjectController.mjs
  37. 1 0
      services/web/app/src/models/UserFeatureUsage.js
  38. 1 0
      services/web/config/settings.defaults.js
  39. 1 0
      services/web/frontend/extracted-translations.json
  40. BIN
      services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2
  41. 1 0
      services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs
  42. 3 1
      services/web/frontend/js/features/ide-redesign/components/rail/rail-panel.tsx
  43. 22 3
      services/web/frontend/js/features/ide-redesign/components/rail/rail.tsx
  44. 1 0
      services/web/frontend/js/features/ide-redesign/contexts/rail-context.tsx
  45. 1 1
      services/web/frontend/js/features/ide-redesign/utils/rail-types.ts
  46. 1 0
      services/web/locales/en.json
  47. 4 0
      services/web/package.json
  48. 1 0
      services/web/tsconfig.json
  49. 22 0
      services/web/webpack.config.js

+ 21 - 0
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"
+  }
+}

+ 13 - 0
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.

+ 1 - 0
libraries/ai/components/ai-elements/README.md

@@ -0,0 +1 @@
+AI Elements components are derived from https://github.com/vercel/ai-elements

+ 97 - 0
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<typeof StickToBottom>
+
+export const Conversation = ({ className, ...props }: ConversationProps) => (
+  <StickToBottom
+    className={cn('relative flex-1 overflow-y-auto', className)}
+    initial="smooth"
+    resize="smooth"
+    role="log"
+    {...props}
+  />
+)
+
+export type ConversationContentProps = ComponentProps<
+  typeof StickToBottom.Content
+>
+
+export const ConversationContent = ({
+  className,
+  ...props
+}: ConversationContentProps) => (
+  <StickToBottom.Content className={cn('p-4', className)} {...props} />
+)
+
+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) => (
+  <div
+    className={cn(
+      'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
+      className
+    )}
+    {...props}
+  >
+    {children ?? (
+      <>
+        {icon && <div className="text-muted-foreground">{icon}</div>}
+        <div className="space-y-1">
+          <h3 className="font-medium text-sm">{title}</h3>
+          {description && (
+            <p className="text-muted-foreground text-sm">{description}</p>
+          )}
+        </div>
+      </>
+    )}
+  </div>
+)
+
+export type ConversationScrollButtonProps = ComponentProps<typeof Button>
+
+export const ConversationScrollButton = ({
+  className,
+  ...props
+}: ConversationScrollButtonProps) => {
+  const { isAtBottom, scrollToBottom } = useStickToBottomContext()
+
+  const handleScrollToBottom = useCallback(() => {
+    scrollToBottom()
+  }, [scrollToBottom])
+
+  return (
+    !isAtBottom && (
+      <Button
+        className={cn(
+          'absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full',
+          className
+        )}
+        onClick={handleScrollToBottom}
+        size="icon"
+        type="button"
+        variant="outline"
+        {...props}
+      >
+        <ArrowDownIcon className="size-4" />
+      </Button>
+    )
+  )
+}

+ 58 - 0
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<HTMLDivElement> & {
+  from: UIMessage['role']
+}
+
+export const Message = ({ className, from, ...props }: MessageProps) => (
+  <div
+    className={cn(
+      'group flex w-full items-end justify-end gap-2 py-4',
+      from === 'user' ? 'is-user' : 'is-assistant flex-row-reverse justify-end',
+      className
+    )}
+    {...props}
+  />
+)
+
+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<HTMLDivElement> &
+  VariantProps<typeof messageContentVariants>
+
+export const MessageContent = ({
+  children,
+  className,
+  variant,
+  ...props
+}: MessageContentProps) => (
+  <div
+    className={cn(messageContentVariants({ variant, className }))}
+    {...props}
+  >
+    {children}
+  </div>
+)

+ 1272 - 0
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<HTMLInputElement | null>
+}
+
+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<HTMLInputElement | null>,
+    open: () => void
+  ) => void
+}
+
+const PromptInputController = createContext<PromptInputControllerProps | null>(
+  null
+)
+const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
+  null
+)
+
+export const usePromptInputController = () => {
+  const ctx = useContext(PromptInputController)
+  if (!ctx) {
+    throw new Error(
+      'Wrap your component inside <PromptInputProvider> 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 <PromptInputProvider> 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<HTMLInputElement | null>(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<AttachmentsContext>(
+    () => ({
+      files: attachements,
+      add,
+      remove,
+      clear,
+      openFileDialog,
+      fileInputRef,
+    }),
+    [attachements, add, remove, clear, openFileDialog]
+  )
+
+  const __registerFileInput = useCallback(
+    (ref: RefObject<HTMLInputElement | null>, open: () => void) => {
+      fileInputRef.current = ref.current
+      openRef.current = open
+    },
+    []
+  )
+
+  const controller = useMemo<PromptInputControllerProps>(
+    () => ({
+      textInput: {
+        value: textInput,
+        setInput: setTextInput,
+        clear: clearInput,
+      },
+      attachments,
+      __registerFileInput,
+    }),
+    [textInput, clearInput, attachments, __registerFileInput]
+  )
+
+  return (
+    <PromptInputController.Provider value={controller}>
+      <ProviderAttachmentsContext.Provider value={attachments}>
+        {children}
+      </ProviderAttachmentsContext.Provider>
+    </PromptInputController.Provider>
+  )
+}
+
+// ============================================================================
+// Component Context & Hooks
+// ============================================================================
+
+const LocalAttachmentsContext = createContext<AttachmentsContext | null>(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<HTMLDivElement> & {
+  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 (
+    <PromptInputHoverCard>
+      <HoverCardTrigger asChild>
+        <div
+          className={cn(
+            'group relative flex h-8 cursor-default select-none items-center gap-1.5 rounded-md border border-border px-1.5 font-medium text-sm transition-all hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
+            className
+          )}
+          key={data.id}
+          {...props}
+        >
+          <div className="relative size-5 shrink-0">
+            <div className="absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded bg-background transition-opacity group-hover:opacity-0">
+              {isImage ? (
+                <img
+                  alt={filename || 'attachment'}
+                  className="size-5 object-cover"
+                  height={20}
+                  src={data.url}
+                  width={20}
+                />
+              ) : (
+                <div className="flex size-5 items-center justify-center text-muted-foreground">
+                  <PaperclipIcon className="size-3" />
+                </div>
+              )}
+            </div>
+            <Button
+              aria-label="Remove attachment"
+              className="absolute inset-0 size-5 cursor-pointer rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-2.5"
+              onClick={e => {
+                e.stopPropagation()
+                attachments.remove(data.id)
+              }}
+              type="button"
+              variant="ghost"
+            >
+              <XIcon />
+              <span className="sr-only">Remove</span>
+            </Button>
+          </div>
+
+          <span className="flex-1 truncate">{attachmentLabel}</span>
+        </div>
+      </HoverCardTrigger>
+      <PromptInputHoverCardContent className="w-auto p-2">
+        <div className="w-auto space-y-3">
+          {isImage && (
+            <div className="flex max-h-96 w-96 items-center justify-center overflow-hidden rounded-md border">
+              <img
+                alt={filename || 'attachment preview'}
+                className="max-h-full max-w-full object-contain"
+                height={384}
+                src={data.url}
+                width={448}
+              />
+            </div>
+          )}
+          <div className="flex items-center gap-2.5">
+            <div className="min-w-0 flex-1 space-y-1 px-0.5">
+              <h4 className="truncate font-semibold text-sm leading-none">
+                {filename || (isImage ? 'Image' : 'Attachment')}
+              </h4>
+              {data.mediaType && (
+                <p className="truncate font-mono text-muted-foreground text-xs">
+                  {data.mediaType}
+                </p>
+              )}
+            </div>
+          </div>
+        </div>
+      </PromptInputHoverCardContent>
+    </PromptInputHoverCard>
+  )
+}
+
+export type PromptInputAttachmentsProps = Omit<
+  HTMLAttributes<HTMLDivElement>,
+  '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 => (
+    <Fragment key={file.id}>{children(file)}</Fragment>
+  ))
+}
+
+export type PromptInputActionAddAttachmentsProps = ComponentProps<
+  typeof DropdownMenuItem
+> & {
+  label?: string
+}
+
+export const PromptInputActionAddAttachments = ({
+  label = 'Add photos or files',
+  ...props
+}: PromptInputActionAddAttachmentsProps) => {
+  const attachments = usePromptInputAttachments()
+
+  return (
+    <DropdownMenuItem
+      {...props}
+      onSelect={e => {
+        e.preventDefault()
+        attachments.openFileDialog()
+      }}
+    >
+      <ImageIcon className="mr-2 size-4" /> {label}
+    </DropdownMenuItem>
+  )
+}
+
+export type PromptInputMessage = {
+  text?: string
+  files?: FileUIPart[]
+}
+
+export type PromptInputProps = Omit<
+  HTMLAttributes<HTMLFormElement>,
+  '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<HTMLFormElement>
+  ) => void | Promise<void>
+}
+
+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<HTMLInputElement | null>(null)
+  const anchorRef = useRef<HTMLSpanElement>(null)
+  const formRef = useRef<HTMLFormElement | null>(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<HTMLInputElement> = event => {
+    if (event.currentTarget.files) {
+      add(event.currentTarget.files)
+    }
+  }
+
+  const convertBlobUrlToDataUrl = async (url: string): Promise<string> => {
+    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<AttachmentsContext>(
+    () => ({
+      files: files.map(item => ({ ...item, id: item.id })),
+      add,
+      remove,
+      clear,
+      openFileDialog,
+      fileInputRef: inputRef,
+    }),
+    [files, add, remove, clear, openFileDialog]
+  )
+
+  const handleSubmit: FormEventHandler<HTMLFormElement> = 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 = (
+    <>
+      <span aria-hidden="true" className="hidden" ref={anchorRef} />
+      <input
+        accept={accept}
+        aria-label="Upload files"
+        className="hidden"
+        multiple={multiple}
+        onChange={handleChange}
+        ref={inputRef}
+        title="Upload files"
+        type="file"
+      />
+      <form
+        className={cn('w-full', className)}
+        onSubmit={handleSubmit}
+        {...props}
+      >
+        <InputGroup>{children}</InputGroup>
+      </form>
+    </>
+  )
+
+  return usingProvider ? (
+    inner
+  ) : (
+    <LocalAttachmentsContext.Provider value={ctx}>
+      {inner}
+    </LocalAttachmentsContext.Provider>
+  )
+}
+
+export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>
+
+export const PromptInputBody = ({
+  className,
+  ...props
+}: PromptInputBodyProps) => (
+  <div className={cn('contents', className)} {...props} />
+)
+
+export type PromptInputTextareaProps = ComponentProps<typeof InputGroupTextarea>
+
+export const PromptInputTextarea = ({
+  onChange,
+  className,
+  placeholder = 'What would you like to do?',
+  ...props
+}: PromptInputTextareaProps) => {
+  const controller = useOptionalPromptInputController()
+  const attachments = usePromptInputAttachments()
+  const [isComposing, setIsComposing] = useState(false)
+
+  const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = e => {
+    if (e.key === 'Enter') {
+      if (isComposing || e.nativeEvent.isComposing) {
+        return
+      }
+      if (e.shiftKey) {
+        return
+      }
+      e.preventDefault()
+      e.currentTarget.form?.requestSubmit()
+    }
+
+    // Remove last attachment when Backspace is pressed and textarea is empty
+    if (
+      e.key === 'Backspace' &&
+      e.currentTarget.value === '' &&
+      attachments.files.length > 0
+    ) {
+      e.preventDefault()
+      const lastAttachment = attachments.files.at(-1)
+      if (lastAttachment) {
+        attachments.remove(lastAttachment.id)
+      }
+    }
+  }
+
+  const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = event => {
+    const items = event.clipboardData?.items
+
+    if (!items) {
+      return
+    }
+
+    const files: File[] = []
+
+    for (const item of items) {
+      if (item.kind === 'file') {
+        const file = item.getAsFile()
+        if (file) {
+          files.push(file)
+        }
+      }
+    }
+
+    if (files.length > 0) {
+      event.preventDefault()
+      attachments.add(files)
+    }
+  }
+
+  const controlledProps = controller
+    ? {
+        value: controller.textInput.value,
+        onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
+          controller.textInput.setInput(e.currentTarget.value)
+          onChange?.(e)
+        },
+      }
+    : {
+        onChange,
+      }
+
+  return (
+    <InputGroupTextarea
+      className={cn('field-sizing-content max-h-48 min-h-16', className)}
+      name="message"
+      onCompositionEnd={() => setIsComposing(false)}
+      onCompositionStart={() => setIsComposing(true)}
+      onKeyDown={handleKeyDown}
+      onPaste={handlePaste}
+      placeholder={placeholder}
+      {...props}
+      {...controlledProps}
+    />
+  )
+}
+
+export type PromptInputHeaderProps = Omit<
+  ComponentProps<typeof InputGroupAddon>,
+  'align'
+>
+
+export const PromptInputHeader = ({
+  className,
+  ...props
+}: PromptInputHeaderProps) => (
+  <InputGroupAddon
+    align="block-end"
+    className={cn('order-first flex-wrap gap-1', className)}
+    {...props}
+  />
+)
+
+export type PromptInputFooterProps = Omit<
+  ComponentProps<typeof InputGroupAddon>,
+  'align'
+>
+
+export const PromptInputFooter = ({
+  className,
+  ...props
+}: PromptInputFooterProps) => (
+  <InputGroupAddon
+    align="block-end"
+    className={cn('justify-between gap-1', className)}
+    {...props}
+  />
+)
+
+export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>
+
+export const PromptInputTools = ({
+  className,
+  ...props
+}: PromptInputToolsProps) => (
+  <div className={cn('flex items-center gap-1', className)} {...props} />
+)
+
+export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>
+
+export const PromptInputButton = ({
+  variant = 'ghost',
+  className,
+  size,
+  ...props
+}: PromptInputButtonProps) => {
+  const newSize =
+    size ?? (Children.count(props.children) > 1 ? 'sm' : 'icon-sm')
+
+  return (
+    <InputGroupButton
+      className={cn(className)}
+      size={newSize}
+      type="button"
+      variant={variant}
+      {...props}
+    />
+  )
+}
+
+export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>
+export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
+  <DropdownMenu {...props} />
+)
+
+export type PromptInputActionMenuTriggerProps = PromptInputButtonProps
+
+export const PromptInputActionMenuTrigger = ({
+  className,
+  children,
+  ...props
+}: PromptInputActionMenuTriggerProps) => (
+  <DropdownMenuTrigger asChild>
+    <PromptInputButton className={className} {...props}>
+      {children ?? <PlusIcon className="size-4" />}
+    </PromptInputButton>
+  </DropdownMenuTrigger>
+)
+
+export type PromptInputActionMenuContentProps = ComponentProps<
+  typeof DropdownMenuContent
+>
+export const PromptInputActionMenuContent = ({
+  className,
+  ...props
+}: PromptInputActionMenuContentProps) => (
+  <DropdownMenuContent align="start" className={cn(className)} {...props} />
+)
+
+export type PromptInputActionMenuItemProps = ComponentProps<
+  typeof DropdownMenuItem
+>
+export const PromptInputActionMenuItem = ({
+  className,
+  ...props
+}: PromptInputActionMenuItemProps) => (
+  <DropdownMenuItem className={cn(className)} {...props} />
+)
+
+// Note: Actions that perform side-effects (like opening a file dialog)
+// are provided in opt-in modules (e.g., prompt-input-attachments).
+
+export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
+  status?: ChatStatus
+}
+
+export const PromptInputSubmit = ({
+  className,
+  variant = 'default',
+  size = 'icon-sm',
+  status,
+  children,
+  ...props
+}: PromptInputSubmitProps) => {
+  let Icon = <SendIcon className="size-4" />
+
+  if (status === 'submitted') {
+    Icon = <Loader2Icon className="size-4 animate-spin" />
+  } else if (status === 'streaming') {
+    Icon = <SquareIcon className="size-4" />
+  } else if (status === 'error') {
+    Icon = <XIcon className="size-4" />
+  }
+
+  return (
+    <InputGroupButton
+      aria-label="Submit"
+      className={cn(className)}
+      size={size}
+      type="submit"
+      variant={variant}
+      {...props}
+    >
+      {children ?? Icon}
+    </InputGroupButton>
+  )
+}
+
+interface SpeechRecognition extends EventTarget {
+  continuous: boolean
+  interimResults: boolean
+  lang: string
+  start(): void
+  stop(): void
+  onstart: ((this: SpeechRecognition, ev: Event) => any) | null
+  onend: ((this: SpeechRecognition, ev: Event) => any) | null
+  onresult:
+    | ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any)
+    | null
+  onerror:
+    | ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any)
+    | null
+}
+
+interface SpeechRecognitionEvent extends Event {
+  results: SpeechRecognitionResultList
+}
+
+type SpeechRecognitionResultList = {
+  readonly length: number
+  item(index: number): SpeechRecognitionResult
+  [index: number]: SpeechRecognitionResult
+}
+
+type SpeechRecognitionResult = {
+  readonly length: number
+  item(index: number): SpeechRecognitionAlternative
+  [index: number]: SpeechRecognitionAlternative
+  isFinal: boolean
+}
+
+type SpeechRecognitionAlternative = {
+  transcript: string
+  confidence: number
+}
+
+interface SpeechRecognitionErrorEvent extends Event {
+  error: string
+}
+
+declare global {
+  // eslint-disable-next-line no-unused-vars
+  interface Window {
+    SpeechRecognition: {
+      new (): SpeechRecognition
+    }
+    webkitSpeechRecognition: {
+      new (): SpeechRecognition
+    }
+  }
+}
+
+export type PromptInputSpeechButtonProps = ComponentProps<
+  typeof PromptInputButton
+> & {
+  textareaRef?: RefObject<HTMLTextAreaElement | null>
+  onTranscriptionChange?: (text: string) => void
+}
+
+export const PromptInputSpeechButton = ({
+  className,
+  textareaRef,
+  onTranscriptionChange,
+  ...props
+}: PromptInputSpeechButtonProps) => {
+  const [isListening, setIsListening] = useState(false)
+  const [recognition, setRecognition] = useState<SpeechRecognition | null>(null)
+  const recognitionRef = useRef<SpeechRecognition | null>(null)
+
+  useEffect(() => {
+    if (
+      typeof window !== 'undefined' &&
+      ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)
+    ) {
+      const SpeechRecognition =
+        window.SpeechRecognition || window.webkitSpeechRecognition
+      const speechRecognition = new SpeechRecognition()
+
+      speechRecognition.continuous = true
+      speechRecognition.interimResults = true
+      speechRecognition.lang = 'en-US'
+
+      speechRecognition.onstart = () => {
+        setIsListening(true)
+      }
+
+      speechRecognition.onend = () => {
+        setIsListening(false)
+      }
+
+      speechRecognition.onresult = event => {
+        let finalTranscript = ''
+
+        const results = Array.from(event.results)
+
+        for (const result of results) {
+          if (result.isFinal) {
+            finalTranscript += result[0]?.transcript ?? ''
+          }
+        }
+
+        if (finalTranscript && textareaRef?.current) {
+          const textarea = textareaRef.current
+          const currentValue = textarea.value
+          const newValue =
+            currentValue + (currentValue ? ' ' : '') + finalTranscript
+
+          textarea.value = newValue
+          textarea.dispatchEvent(new Event('input', { bubbles: true }))
+          onTranscriptionChange?.(newValue)
+        }
+      }
+
+      speechRecognition.onerror = event => {
+        console.error('Speech recognition error:', event.error)
+        setIsListening(false)
+      }
+
+      recognitionRef.current = speechRecognition
+      setRecognition(speechRecognition)
+    }
+
+    return () => {
+      if (recognitionRef.current) {
+        recognitionRef.current.stop()
+      }
+    }
+  }, [textareaRef, onTranscriptionChange])
+
+  const toggleListening = useCallback(() => {
+    if (!recognition) {
+      return
+    }
+
+    if (isListening) {
+      recognition.stop()
+    } else {
+      recognition.start()
+    }
+  }, [recognition, isListening])
+
+  return (
+    <PromptInputButton
+      className={cn(
+        'relative transition-all duration-200',
+        isListening && 'animate-pulse bg-accent text-accent-foreground',
+        className
+      )}
+      disabled={!recognition}
+      onClick={toggleListening}
+      {...props}
+    >
+      <MicIcon className="size-4" />
+    </PromptInputButton>
+  )
+}
+
+export type PromptInputModelSelectProps = ComponentProps<typeof Select>
+
+export const PromptInputModelSelect = (props: PromptInputModelSelectProps) => (
+  <Select {...props} />
+)
+
+export type PromptInputModelSelectTriggerProps = ComponentProps<
+  typeof SelectTrigger
+>
+
+export const PromptInputModelSelectTrigger = ({
+  className,
+  ...props
+}: PromptInputModelSelectTriggerProps) => (
+  <SelectTrigger
+    className={cn(
+      'border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors',
+      'hover:bg-accent hover:text-foreground [&[aria-expanded="true"]]:bg-accent [&[aria-expanded="true"]]:text-foreground',
+      className
+    )}
+    {...props}
+  />
+)
+
+export type PromptInputModelSelectContentProps = ComponentProps<
+  typeof SelectContent
+>
+
+export const PromptInputModelSelectContent = ({
+  className,
+  ...props
+}: PromptInputModelSelectContentProps) => (
+  <SelectContent className={cn(className)} {...props} />
+)
+
+export type PromptInputModelSelectItemProps = ComponentProps<typeof SelectItem>
+
+export const PromptInputModelSelectItem = ({
+  className,
+  ...props
+}: PromptInputModelSelectItemProps) => (
+  <SelectItem className={cn(className)} {...props} />
+)
+
+export type PromptInputModelSelectValueProps = ComponentProps<
+  typeof SelectValue
+>
+
+export const PromptInputModelSelectValue = ({
+  className,
+  ...props
+}: PromptInputModelSelectValueProps) => (
+  <SelectValue className={cn(className)} {...props} />
+)
+
+export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>
+
+export const PromptInputHoverCard = ({
+  openDelay = 0,
+  closeDelay = 0,
+  ...props
+}: PromptInputHoverCardProps) => (
+  <HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
+)
+
+export type PromptInputHoverCardTriggerProps = ComponentProps<
+  typeof HoverCardTrigger
+>
+
+export const PromptInputHoverCardTrigger = (
+  props: PromptInputHoverCardTriggerProps
+) => <HoverCardTrigger {...props} />
+
+export type PromptInputHoverCardContentProps = ComponentProps<
+  typeof HoverCardContent
+>
+
+export const PromptInputHoverCardContent = ({
+  align = 'start',
+  ...props
+}: PromptInputHoverCardContentProps) => (
+  <HoverCardContent align={align} {...props} />
+)
+
+export type PromptInputTabsListProps = HTMLAttributes<HTMLDivElement>
+
+export const PromptInputTabsList = ({
+  className,
+  ...props
+}: PromptInputTabsListProps) => <div className={cn(className)} {...props} />
+
+export type PromptInputTabProps = HTMLAttributes<HTMLDivElement>
+
+export const PromptInputTab = ({
+  className,
+  ...props
+}: PromptInputTabProps) => <div className={cn(className)} {...props} />
+
+export type PromptInputTabLabelProps = HTMLAttributes<HTMLHeadingElement>
+
+export const PromptInputTabLabel = ({
+  className,
+  ...props
+}: PromptInputTabLabelProps) => (
+  <h3
+    className={cn(
+      'mb-2 px-3 font-medium text-muted-foreground text-xs',
+      className
+    )}
+    {...props}
+  />
+)
+
+export type PromptInputTabBodyProps = HTMLAttributes<HTMLDivElement>
+
+export const PromptInputTabBody = ({
+  className,
+  ...props
+}: PromptInputTabBodyProps) => (
+  <div className={cn('space-y-1', className)} {...props} />
+)
+
+export type PromptInputTabItemProps = HTMLAttributes<HTMLDivElement>
+
+export const PromptInputTabItem = ({
+  className,
+  ...props
+}: PromptInputTabItemProps) => (
+  <div
+    className={cn(
+      'flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent',
+      className
+    )}
+    {...props}
+  />
+)

+ 178 - 0
libraries/ai/components/ai-elements/reasoning.tsx

@@ -0,0 +1,178 @@
+'use client'
+
+import { useControllableState } from '@radix-ui/react-use-controllable-state'
+import {
+  Collapsible,
+  CollapsibleContent,
+  CollapsibleTrigger,
+} from '../ui/collapsible'
+import { cn } from '../../utils'
+import { BrainIcon, ChevronDownIcon } from 'lucide-react'
+import type { ComponentProps } from 'react'
+import { createContext, memo, useContext, useEffect, useState } from 'react'
+import { Response } from './response'
+import { Shimmer } from './shimmer'
+
+type ReasoningContextValue = {
+  isStreaming: boolean
+  isOpen: boolean
+  setIsOpen: (open: boolean) => void
+  duration: number
+}
+
+const ReasoningContext = createContext<ReasoningContextValue | null>(null)
+
+const useReasoning = () => {
+  const context = useContext(ReasoningContext)
+  if (!context) {
+    throw new Error('Reasoning components must be used within Reasoning')
+  }
+  return context
+}
+
+export type ReasoningProps = ComponentProps<typeof Collapsible> & {
+  isStreaming?: boolean
+  open?: boolean
+  defaultOpen?: boolean
+  onOpenChange?: (open: boolean) => void
+  duration?: number
+}
+
+const AUTO_CLOSE_DELAY = 1000
+const MS_IN_S = 1000
+
+export const Reasoning = memo(
+  ({
+    className,
+    isStreaming = false,
+    open,
+    defaultOpen = true,
+    onOpenChange,
+    duration: durationProp,
+    children,
+    ...props
+  }: ReasoningProps) => {
+    const [isOpen, setIsOpen] = useControllableState({
+      prop: open,
+      defaultProp: defaultOpen,
+      onChange: onOpenChange,
+    })
+    const [duration, setDuration] = useControllableState({
+      prop: durationProp,
+      defaultProp: 0,
+    })
+
+    const [hasAutoClosed, setHasAutoClosed] = useState(false)
+    const [startTime, setStartTime] = useState<number | null>(null)
+
+    // Track duration when streaming starts and ends
+    useEffect(() => {
+      if (isStreaming) {
+        if (startTime === null) {
+          setStartTime(Date.now())
+        }
+      } else if (startTime !== null) {
+        setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
+        setStartTime(null)
+      }
+    }, [isStreaming, startTime, setDuration])
+
+    // Auto-open when streaming starts, auto-close when streaming ends (once only)
+    useEffect(() => {
+      if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
+        // Add a small delay before closing to allow user to see the content
+        const timer = setTimeout(() => {
+          setIsOpen(false)
+          setHasAutoClosed(true)
+        }, AUTO_CLOSE_DELAY)
+
+        return () => clearTimeout(timer)
+      }
+    }, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed])
+
+    const handleOpenChange = (newOpen: boolean) => {
+      setIsOpen(newOpen)
+    }
+
+    return (
+      <ReasoningContext.Provider
+        value={{ isStreaming, isOpen, setIsOpen, duration }}
+      >
+        <Collapsible
+          className={cn('not-prose mb-4', className)}
+          onOpenChange={handleOpenChange}
+          open={isOpen}
+          {...props}
+        >
+          {children}
+        </Collapsible>
+      </ReasoningContext.Provider>
+    )
+  }
+)
+
+export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>
+
+const getThinkingMessage = (isStreaming: boolean, duration?: number) => {
+  if (isStreaming || duration === 0) {
+    return <Shimmer duration={1}>Thinking...</Shimmer>
+  }
+  if (duration === undefined) {
+    return <p>Thought for a few seconds</p>
+  }
+  return <p>Thought for {duration} seconds</p>
+}
+
+export const ReasoningTrigger = memo(
+  ({ className, children, ...props }: ReasoningTriggerProps) => {
+    const { isStreaming, isOpen, duration } = useReasoning()
+
+    return (
+      <CollapsibleTrigger
+        className={cn(
+          'flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground',
+          className
+        )}
+        {...props}
+      >
+        {children ?? (
+          <>
+            <BrainIcon className="size-4" />
+            {getThinkingMessage(isStreaming, duration)}
+            <ChevronDownIcon
+              className={cn(
+                'size-4 transition-transform',
+                isOpen ? 'rotate-180' : 'rotate-0'
+              )}
+            />
+          </>
+        )}
+      </CollapsibleTrigger>
+    )
+  }
+)
+
+export type ReasoningContentProps = ComponentProps<
+  typeof CollapsibleContent
+> & {
+  children: string
+}
+
+export const ReasoningContent = memo(
+  ({ className, children, ...props }: ReasoningContentProps) => (
+    <CollapsibleContent
+      className={cn(
+        'mt-4 text-sm',
+        'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
+        className
+      )}
+      {...props}
+    >
+      <Response className="grid gap-2">{children}</Response>
+    </CollapsibleContent>
+  )
+)
+
+Reasoning.displayName = 'Reasoning'
+ReasoningTrigger.displayName = 'ReasoningTrigger'
+ReasoningContent.displayName = 'ReasoningContent'

+ 22 - 0
libraries/ai/components/ai-elements/response.tsx

@@ -0,0 +1,22 @@
+'use client'
+
+import { cn } from '../../utils'
+import { type ComponentProps, memo } from 'react'
+import { Streamdown } from 'streamdown'
+
+type ResponseProps = ComponentProps<typeof Streamdown>
+
+export const Response = memo(
+  ({ className, ...props }: ResponseProps) => (
+    <Streamdown
+      className={cn(
+        'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
+        className
+      )}
+      {...props}
+    />
+  ),
+  (prevProps, nextProps) => prevProps.children === nextProps.children
+)
+
+Response.displayName = 'Response'

+ 64 - 0
libraries/ai/components/ai-elements/shimmer.tsx

@@ -0,0 +1,64 @@
+'use client'
+
+import { cn } from '../../utils'
+import { motion } from 'motion/react'
+import {
+  type CSSProperties,
+  type ElementType,
+  type JSX,
+  memo,
+  useMemo,
+} from 'react'
+
+export type TextShimmerProps = {
+  children: string
+  as?: ElementType
+  className?: string
+  duration?: number
+  spread?: number
+}
+
+const ShimmerComponent = ({
+  children,
+  as: Component = 'p',
+  className,
+  duration = 2,
+  spread = 2,
+}: TextShimmerProps) => {
+  const MotionComponent = motion.create(
+    Component as keyof JSX.IntrinsicElements
+  )
+
+  const dynamicSpread = useMemo(
+    () => (children?.length ?? 0) * spread,
+    [children, spread]
+  )
+
+  return (
+    <MotionComponent
+      animate={{ backgroundPosition: '0% center' }}
+      className={cn(
+        'relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent',
+        '[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]',
+        className
+      )}
+      initial={{ backgroundPosition: '100% center' }}
+      style={
+        {
+          '--spread': `${dynamicSpread}px`,
+          backgroundImage:
+            'var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))',
+        } as CSSProperties
+      }
+      transition={{
+        repeat: Number.POSITIVE_INFINITY,
+        duration,
+        ease: 'linear',
+      }}
+    >
+      {children}
+    </MotionComponent>
+  )
+}
+
+export const Shimmer = memo(ShimmerComponent)

+ 51 - 0
libraries/ai/components/chat-prompt-input.tsx

@@ -0,0 +1,51 @@
+import { FC } from 'react'
+import {
+  PromptInput,
+  PromptInputAttachment,
+  PromptInputAttachments,
+  PromptInputBody,
+  PromptInputFooter,
+  PromptInputHeader,
+  PromptInputMessage,
+  PromptInputSubmit,
+  PromptInputSubmitProps,
+  PromptInputTextarea,
+  PromptInputTools,
+  usePromptInputController,
+} from './ai-elements/prompt-input'
+
+export const ChatPromptInput: FC<{
+  status: PromptInputSubmitProps['status']
+  onSubmit: (message: PromptInputMessage) => Promise<boolean>
+}> = ({ status, onSubmit }) => {
+  const controller = usePromptInputController()
+
+  return (
+    <PromptInput
+      onSubmit={async message => {
+        const result = await onSubmit(message)
+        if (result) {
+          controller.textInput.clear()
+        }
+      }}
+      globalDrop
+      multiple
+      className="mt-4"
+    >
+      <PromptInputHeader>
+        <PromptInputAttachments>
+          {attachment => <PromptInputAttachment data={attachment} />}
+        </PromptInputAttachments>
+      </PromptInputHeader>
+
+      <PromptInputBody>
+        <PromptInputTextarea />
+      </PromptInputBody>
+
+      <PromptInputFooter>
+        <PromptInputTools />
+        <PromptInputSubmit status={status} />
+      </PromptInputFooter>
+    </PromptInput>
+  )
+}

+ 187 - 0
libraries/ai/components/chat.tsx

@@ -0,0 +1,187 @@
+import { FC, Fragment, ReactElement, useCallback } from 'react'
+import {
+  Message,
+  MessageContent,
+  Response,
+  Conversation,
+  ConversationContent,
+  ConversationEmptyState,
+  ConversationScrollButton,
+  Reasoning,
+  ReasoningContent,
+  ReasoningTrigger,
+  PromptInputProvider,
+  PromptInputMessage,
+  Skeleton,
+  Alert,
+} from '../'
+import { cn } from '../utils'
+import { ShadowRootPortal } from './shadow-root-portal'
+import {
+  DefaultChatTransport,
+  getToolName,
+  isFileUIPart,
+  isReasoningUIPart,
+  isTextUIPart,
+  isToolUIPart,
+  lastAssistantMessageIsCompleteWithToolCalls,
+  UIMessage,
+  UIMessagePart,
+} from 'ai'
+import { useChat, UseChatHelpers } from '@ai-sdk/react'
+import { ChatPromptInput } from './chat-prompt-input'
+
+export type ChatRunners = Record<string, (input: any) => any | Promise<any>>
+
+export type ChatRenderers = Record<
+  string,
+  (
+    part: UIMessagePart<any, any>,
+    chat: UseChatHelpers<UIMessage>
+  ) => ReactElement
+>
+
+export const Chat: FC<{
+  className: string
+  chatId: string
+  api: string
+  headers: Record<string, string>
+  runners: ChatRunners
+  renderers: ChatRenderers
+}> = ({ className, chatId, api, headers, runners, renderers }) => {
+  const chat = useChat({
+    id: chatId,
+    transport: new DefaultChatTransport({ api, headers }),
+    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
+    async onToolCall({ toolCall }) {
+      if (toolCall.dynamic) {
+        return
+      }
+
+      const run = runners[toolCall.toolName]
+
+      if (run) {
+        const output = run(toolCall.input)
+
+        chat.addToolResult({
+          tool: toolCall.toolName,
+          toolCallId: toolCall.toolCallId,
+          output,
+        })
+      }
+    },
+  })
+
+  const handleSubmit = useCallback(
+    async (message: PromptInputMessage) => {
+      if (chat.status === 'streaming' || chat.status === 'submitted') {
+        await chat.stop()
+        return false
+      }
+
+      if (chat.status !== 'ready') {
+        return false
+      }
+
+      const text = message.text?.trim()
+      if (!text || text.length === 0) {
+        return false
+      }
+
+      chat.sendMessage({ text }) // TODO: files, metadata
+      return true
+    },
+    [chat]
+  )
+
+  return (
+    <ShadowRootPortal>
+      <Conversation
+        className={cn('workbench-conversation', 'h-full', className)}
+      >
+        <ConversationContent>
+          {chat.messages.length === 0 ? (
+            <ConversationEmptyState />
+          ) : (
+            chat.messages.map(message => (
+              <div key={message.id}>
+                <Message from={message.role}>
+                  <MessageContent variant="flat">
+                    {message.parts.map((part, i) => {
+                      const key = `${message.role}-${i}`
+
+                      if (isTextUIPart(part)) {
+                        return <Response key={key}>{part.text}</Response>
+                      }
+
+                      if (isReasoningUIPart(part)) {
+                        const isStreaming =
+                          chat.status === 'streaming' &&
+                          i === message.parts.length - 1 &&
+                          message.id === chat.messages.at(-1)?.id
+
+                        return (
+                          <Reasoning
+                            key={key}
+                            className="w-full"
+                            isStreaming={isStreaming}
+                            defaultOpen={false}
+                          >
+                            <ReasoningTrigger />
+                            <ReasoningContent>{part.text}</ReasoningContent>
+                          </Reasoning>
+                        )
+                      }
+
+                      if (isFileUIPart(part)) {
+                        return <div>{part.filename}</div>
+                      }
+
+                      if (isToolUIPart(part)) {
+                        switch (part.state) {
+                          case 'input-streaming':
+                            return (
+                              <Skeleton
+                                key={key}
+                                className="h-[40px] w-full rounded-full"
+                              />
+                            )
+
+                          case 'input-available':
+                          case 'output-available': {
+                            const toolName = getToolName(part)
+                            const render = renderers[toolName]
+
+                            if (!render) {
+                              // TODO: error message
+                              return null
+                            }
+
+                            return (
+                              <Fragment key={key}>
+                                {render(part, chat)}
+                              </Fragment>
+                            )
+                          }
+
+                          case 'output-error':
+                            return <Alert key={key}>{part.errorText}</Alert>
+                        }
+                      }
+
+                      return null
+                    })}
+                  </MessageContent>
+                </Message>
+              </div>
+            ))
+          )}
+        </ConversationContent>
+        <ConversationScrollButton />
+      </Conversation>
+      <PromptInputProvider>
+        <ChatPromptInput status={chat.status} onSubmit={handleSubmit} />
+      </PromptInputProvider>
+    </ShadowRootPortal>
+  )
+}

+ 32 - 0
libraries/ai/components/shadow-root-portal.tsx

@@ -0,0 +1,32 @@
+import { FC, PropsWithChildren, useEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+// @ts-expect-error this is using exportType: 'css-style-sheet'
+import tailwindCSS from '../tailwind.css'
+
+export const ShadowRootPortal: FC<PropsWithChildren> = ({ children }) => {
+  const [shadowRoot, setShadowRoot] = useState<ShadowRoot | null>(null)
+
+  const hostRef = useRef<HTMLDivElement | null>(null)
+
+  useEffect(() => {
+    if (!shadowRoot && hostRef.current) {
+      const shadowRoot = hostRef.current.attachShadow({ mode: 'open' })
+      shadowRoot.adoptedStyleSheets = [tailwindCSS]
+      setShadowRoot(shadowRoot)
+    }
+  }, [shadowRoot])
+
+  return (
+    <div
+      ref={hostRef}
+      style={{
+        flex: 1,
+        display: 'flex',
+        flexDirection: 'column',
+        overflow: 'auto',
+      }}
+    >
+      {shadowRoot ? createPortal(children, shadowRoot) : null}
+    </div>
+  )
+}

+ 21 - 0
libraries/ai/components/ui/LICENSE.md

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 shadcn
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 1 - 0
libraries/ai/components/ui/README.md

@@ -0,0 +1 @@
+shadcn/ui components are derived from https://github.com/shadcn-ui/ui

+ 66 - 0
libraries/ai/components/ui/alert.tsx

@@ -0,0 +1,66 @@
+import * as React from 'react'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '../../utils'
+
+const alertVariants = cva(
+  'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
+  {
+    variants: {
+      variant: {
+        default: 'bg-card text-card-foreground',
+        destructive:
+          'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
+      },
+    },
+    defaultVariants: {
+      variant: 'default',
+    },
+  }
+)
+
+function Alert({
+  className,
+  variant,
+  ...props
+}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
+  return (
+    <div
+      data-slot="alert"
+      role="alert"
+      className={cn(alertVariants({ variant }), className)}
+      {...props}
+    />
+  )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
+  return (
+    <div
+      data-slot="alert-title"
+      className={cn(
+        'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function AlertDescription({
+  className,
+  ...props
+}: React.ComponentProps<'div'>) {
+  return (
+    <div
+      data-slot="alert-description"
+      className={cn(
+        'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export { Alert, AlertTitle, AlertDescription }

+ 56 - 0
libraries/ai/components/ui/button.tsx

@@ -0,0 +1,56 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '../../utils'
+
+const buttonVariants = cva(
+  'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
+  {
+    variants: {
+      variant: {
+        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+        destructive:
+          'bg-destructive text-destructive-foreground hover:bg-destructive/90',
+        outline:
+          'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
+        secondary:
+          'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+        ghost: 'hover:bg-accent hover:text-accent-foreground',
+        link: 'text-primary underline-offset-4 hover:underline',
+      },
+      size: {
+        default: 'h-10 px-4 py-2',
+        sm: 'h-9 rounded-md px-3',
+        lg: 'h-11 rounded-md px-8',
+        icon: 'h-10 w-10',
+      },
+    },
+    defaultVariants: {
+      variant: 'default',
+      size: 'default',
+    },
+  }
+)
+
+export interface ButtonProps
+  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
+    VariantProps<typeof buttonVariants> {
+  asChild?: boolean
+}
+
+const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
+  ({ className, variant, size, asChild = false, ...props }, ref) => {
+    const Comp = asChild ? Slot : 'button'
+    return (
+      <Comp
+        className={cn(buttonVariants({ variant, size, className }))}
+        ref={ref}
+        {...props}
+      />
+    )
+  }
+)
+Button.displayName = 'Button'
+
+export { Button, buttonVariants }

+ 9 - 0
libraries/ai/components/ui/collapsible.tsx

@@ -0,0 +1,9 @@
+import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
+
+const Collapsible = CollapsiblePrimitive.Root
+
+const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
+
+const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }

+ 200 - 0
libraries/ai/components/ui/dropdown-menu.tsx

@@ -0,0 +1,200 @@
+'use client'
+
+import * as React from 'react'
+import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
+import { Check, ChevronRight, Circle } from 'lucide-react'
+
+import { cn } from '../../utils'
+
+const DropdownMenu = DropdownMenuPrimitive.Root
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
+
+const DropdownMenuSubTrigger = React.forwardRef<
+  React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
+  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
+    inset?: boolean
+  }
+>(({ className, inset, children, ...props }, ref) => (
+  <DropdownMenuPrimitive.SubTrigger
+    ref={ref}
+    className={cn(
+      'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
+      inset && 'pl-8',
+      className
+    )}
+    {...props}
+  >
+    {children}
+    <ChevronRight className="ml-auto" />
+  </DropdownMenuPrimitive.SubTrigger>
+))
+DropdownMenuSubTrigger.displayName =
+  DropdownMenuPrimitive.SubTrigger.displayName
+
+const DropdownMenuSubContent = React.forwardRef<
+  React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
+  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
+>(({ className, ...props }, ref) => (
+  <DropdownMenuPrimitive.SubContent
+    ref={ref}
+    className={cn(
+      'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]',
+      className
+    )}
+    {...props}
+  />
+))
+DropdownMenuSubContent.displayName =
+  DropdownMenuPrimitive.SubContent.displayName
+
+const DropdownMenuContent = React.forwardRef<
+  React.ElementRef<typeof DropdownMenuPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
+>(({ className, sideOffset = 4, ...props }, ref) => (
+  <DropdownMenuPrimitive.Portal>
+    <DropdownMenuPrimitive.Content
+      ref={ref}
+      sideOffset={sideOffset}
+      className={cn(
+        'z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]',
+        className
+      )}
+      {...props}
+    />
+  </DropdownMenuPrimitive.Portal>
+))
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
+
+const DropdownMenuItem = React.forwardRef<
+  React.ElementRef<typeof DropdownMenuPrimitive.Item>,
+  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
+    inset?: boolean
+  }
+>(({ className, inset, ...props }, ref) => (
+  <DropdownMenuPrimitive.Item
+    ref={ref}
+    className={cn(
+      'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
+      inset && 'pl-8',
+      className
+    )}
+    {...props}
+  />
+))
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+  React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
+  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
+>(({ className, children, checked, ...props }, ref) => (
+  <DropdownMenuPrimitive.CheckboxItem
+    ref={ref}
+    className={cn(
+      'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
+      className
+    )}
+    checked={checked}
+    {...props}
+  >
+    <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
+      <DropdownMenuPrimitive.ItemIndicator>
+        <Check className="h-4 w-4" />
+      </DropdownMenuPrimitive.ItemIndicator>
+    </span>
+    {children}
+  </DropdownMenuPrimitive.CheckboxItem>
+))
+DropdownMenuCheckboxItem.displayName =
+  DropdownMenuPrimitive.CheckboxItem.displayName
+
+const DropdownMenuRadioItem = React.forwardRef<
+  React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
+  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
+>(({ className, children, ...props }, ref) => (
+  <DropdownMenuPrimitive.RadioItem
+    ref={ref}
+    className={cn(
+      'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
+      className
+    )}
+    {...props}
+  >
+    <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
+      <DropdownMenuPrimitive.ItemIndicator>
+        <Circle className="h-2 w-2 fill-current" />
+      </DropdownMenuPrimitive.ItemIndicator>
+    </span>
+    {children}
+  </DropdownMenuPrimitive.RadioItem>
+))
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
+
+const DropdownMenuLabel = React.forwardRef<
+  React.ElementRef<typeof DropdownMenuPrimitive.Label>,
+  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
+    inset?: boolean
+  }
+>(({ className, inset, ...props }, ref) => (
+  <DropdownMenuPrimitive.Label
+    ref={ref}
+    className={cn(
+      'px-2 py-1.5 text-sm font-semibold',
+      inset && 'pl-8',
+      className
+    )}
+    {...props}
+  />
+))
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
+
+const DropdownMenuSeparator = React.forwardRef<
+  React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
+  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+  <DropdownMenuPrimitive.Separator
+    ref={ref}
+    className={cn('-mx-1 my-1 h-px bg-muted', className)}
+    {...props}
+  />
+))
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
+
+const DropdownMenuShortcut = ({
+  className,
+  ...props
+}: React.HTMLAttributes<HTMLSpanElement>) => {
+  return (
+    <span
+      className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
+      {...props}
+    />
+  )
+}
+DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
+
+export {
+  DropdownMenu,
+  DropdownMenuTrigger,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuCheckboxItem,
+  DropdownMenuRadioItem,
+  DropdownMenuLabel,
+  DropdownMenuSeparator,
+  DropdownMenuShortcut,
+  DropdownMenuGroup,
+  DropdownMenuPortal,
+  DropdownMenuSub,
+  DropdownMenuSubContent,
+  DropdownMenuSubTrigger,
+  DropdownMenuRadioGroup,
+}

+ 27 - 0
libraries/ai/components/ui/hover-card.tsx

@@ -0,0 +1,27 @@
+import * as React from 'react'
+import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
+
+import { cn } from '../../utils'
+
+const HoverCard = HoverCardPrimitive.Root
+
+const HoverCardTrigger = HoverCardPrimitive.Trigger
+
+const HoverCardContent = React.forwardRef<
+  React.ElementRef<typeof HoverCardPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
+>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
+  <HoverCardPrimitive.Content
+    ref={ref}
+    align={align}
+    sideOffset={sideOffset}
+    className={cn(
+      'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]',
+      className
+    )}
+    {...props}
+  />
+))
+HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
+
+export { HoverCard, HoverCardTrigger, HoverCardContent }

+ 168 - 0
libraries/ai/components/ui/input-group.tsx

@@ -0,0 +1,168 @@
+import * as React from 'react'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '../../utils'
+import { Button } from '../ui/button'
+import { Input } from '../ui/input'
+import { Textarea } from '../ui/textarea'
+
+function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
+  return (
+    <div
+      data-slot="input-group"
+      role="group"
+      className={cn(
+        'group/input-group border-input dark:bg-input/30 shadow-xs relative flex w-full items-center rounded-md border outline-none transition-[color,box-shadow]',
+        'h-9 has-[>textarea]:h-auto',
+
+        // Variants based on alignment.
+        'has-[>[data-align=inline-start]]:[&>input]:pl-2',
+        'has-[>[data-align=inline-end]]:[&>input]:pr-2',
+        'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
+        'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
+
+        // Focus state.
+        'has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1',
+
+        // Error state.
+        'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
+
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+const inputGroupAddonVariants = cva(
+  "text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
+  {
+    variants: {
+      align: {
+        'inline-start':
+          'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
+        'inline-end':
+          'order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]',
+        'block-start':
+          '[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5',
+        'block-end':
+          '[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5',
+      },
+    },
+    defaultVariants: {
+      align: 'inline-start',
+    },
+  }
+)
+
+function InputGroupAddon({
+  className,
+  align = 'inline-start',
+  ...props
+}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
+  return (
+    <div
+      role="group"
+      data-slot="input-group-addon"
+      data-align={align}
+      className={cn(inputGroupAddonVariants({ align }), className)}
+      onClick={e => {
+        if ((e.target as HTMLElement).closest('button')) {
+          return
+        }
+        e.currentTarget.parentElement?.querySelector('input')?.focus()
+      }}
+      {...props}
+    />
+  )
+}
+
+const inputGroupButtonVariants = cva(
+  'flex items-center gap-2 text-sm shadow-none',
+  {
+    variants: {
+      size: {
+        xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
+        sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5',
+        'icon-xs':
+          'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
+        'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
+      },
+    },
+    defaultVariants: {
+      size: 'xs',
+    },
+  }
+)
+
+function InputGroupButton({
+  className,
+  type = 'button',
+  variant = 'ghost',
+  size = 'xs',
+  ...props
+}: Omit<React.ComponentProps<typeof Button>, 'size'> &
+  VariantProps<typeof inputGroupButtonVariants>) {
+  return (
+    <Button
+      type={type}
+      data-size={size}
+      variant={variant}
+      className={cn(inputGroupButtonVariants({ size }), className)}
+      {...props}
+    />
+  )
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
+  return (
+    <span
+      className={cn(
+        "text-muted-foreground flex items-center gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function InputGroupInput({
+  className,
+  ...props
+}: React.ComponentProps<'input'>) {
+  return (
+    <Input
+      data-slot="input-group-control"
+      className={cn(
+        'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function InputGroupTextarea({
+  className,
+  ...props
+}: React.ComponentProps<'textarea'>) {
+  return (
+    <Textarea
+      data-slot="input-group-control"
+      className={cn(
+        'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export {
+  InputGroup,
+  InputGroupAddon,
+  InputGroupButton,
+  InputGroupText,
+  InputGroupInput,
+  InputGroupTextarea,
+}

+ 22 - 0
libraries/ai/components/ui/input.tsx

@@ -0,0 +1,22 @@
+import * as React from 'react'
+
+import { cn } from '../../utils'
+
+const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
+  ({ className, type, ...props }, ref) => {
+    return (
+      <input
+        type={type}
+        className={cn(
+          'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
+          className
+        )}
+        ref={ref}
+        {...props}
+      />
+    )
+  }
+)
+Input.displayName = 'Input'
+
+export { Input }

+ 158 - 0
libraries/ai/components/ui/select.tsx

@@ -0,0 +1,158 @@
+import * as React from 'react'
+import * as SelectPrimitive from '@radix-ui/react-select'
+import { Check, ChevronDown, ChevronUp } from 'lucide-react'
+
+import { cn } from '../../utils'
+
+const Select = SelectPrimitive.Root
+
+const SelectGroup = SelectPrimitive.Group
+
+const SelectValue = SelectPrimitive.Value
+
+const SelectTrigger = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.Trigger>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
+>(({ className, children, ...props }, ref) => (
+  <SelectPrimitive.Trigger
+    ref={ref}
+    className={cn(
+      'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
+      className
+    )}
+    {...props}
+  >
+    {children}
+    <SelectPrimitive.Icon asChild>
+      <ChevronDown className="h-4 w-4 opacity-50" />
+    </SelectPrimitive.Icon>
+  </SelectPrimitive.Trigger>
+))
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
+
+const SelectScrollUpButton = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
+>(({ className, ...props }, ref) => (
+  <SelectPrimitive.ScrollUpButton
+    ref={ref}
+    className={cn(
+      'flex cursor-default items-center justify-center py-1',
+      className
+    )}
+    {...props}
+  >
+    <ChevronUp className="h-4 w-4" />
+  </SelectPrimitive.ScrollUpButton>
+))
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
+
+const SelectScrollDownButton = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
+>(({ className, ...props }, ref) => (
+  <SelectPrimitive.ScrollDownButton
+    ref={ref}
+    className={cn(
+      'flex cursor-default items-center justify-center py-1',
+      className
+    )}
+    {...props}
+  >
+    <ChevronDown className="h-4 w-4" />
+  </SelectPrimitive.ScrollDownButton>
+))
+SelectScrollDownButton.displayName =
+  SelectPrimitive.ScrollDownButton.displayName
+
+const SelectContent = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
+>(({ className, children, position = 'popper', ...props }, ref) => (
+  <SelectPrimitive.Portal>
+    <SelectPrimitive.Content
+      ref={ref}
+      className={cn(
+        'relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]',
+        position === 'popper' &&
+          'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
+        className
+      )}
+      position={position}
+      {...props}
+    >
+      <SelectScrollUpButton />
+      <SelectPrimitive.Viewport
+        className={cn(
+          'p-1',
+          position === 'popper' &&
+            'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
+        )}
+      >
+        {children}
+      </SelectPrimitive.Viewport>
+      <SelectScrollDownButton />
+    </SelectPrimitive.Content>
+  </SelectPrimitive.Portal>
+))
+SelectContent.displayName = SelectPrimitive.Content.displayName
+
+const SelectLabel = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.Label>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
+>(({ className, ...props }, ref) => (
+  <SelectPrimitive.Label
+    ref={ref}
+    className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
+    {...props}
+  />
+))
+SelectLabel.displayName = SelectPrimitive.Label.displayName
+
+const SelectItem = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.Item>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
+>(({ className, children, ...props }, ref) => (
+  <SelectPrimitive.Item
+    ref={ref}
+    className={cn(
+      'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
+      className
+    )}
+    {...props}
+  >
+    <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
+      <SelectPrimitive.ItemIndicator>
+        <Check className="h-4 w-4" />
+      </SelectPrimitive.ItemIndicator>
+    </span>
+
+    <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+  </SelectPrimitive.Item>
+))
+SelectItem.displayName = SelectPrimitive.Item.displayName
+
+const SelectSeparator = React.forwardRef<
+  React.ElementRef<typeof SelectPrimitive.Separator>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+  <SelectPrimitive.Separator
+    ref={ref}
+    className={cn('-mx-1 my-1 h-px bg-muted', className)}
+    {...props}
+  />
+))
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName
+
+export {
+  Select,
+  SelectGroup,
+  SelectValue,
+  SelectTrigger,
+  SelectContent,
+  SelectLabel,
+  SelectItem,
+  SelectSeparator,
+  SelectScrollUpButton,
+  SelectScrollDownButton,
+}

+ 14 - 0
libraries/ai/components/ui/skeleton.tsx

@@ -0,0 +1,14 @@
+import React from 'react'
+import { cn } from '../../utils'
+
+function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
+  return (
+    <div
+      data-slot="skeleton"
+      className={cn('bg-accent animate-pulse rounded-md', className)}
+      {...props}
+    />
+  )
+}
+
+export { Skeleton }

+ 22 - 0
libraries/ai/components/ui/textarea.tsx

@@ -0,0 +1,22 @@
+import * as React from 'react'
+
+import { cn } from '../../utils'
+
+const Textarea = React.forwardRef<
+  HTMLTextAreaElement,
+  React.ComponentProps<'textarea'>
+>(({ className, ...props }, ref) => {
+  return (
+    <textarea
+      className={cn(
+        'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
+        className
+      )}
+      ref={ref}
+      {...props}
+    />
+  )
+})
+Textarea.displayName = 'Textarea'
+
+export { Textarea }

+ 10 - 0
libraries/ai/index.ts

@@ -0,0 +1,10 @@
+export * from './components/ai-elements/conversation'
+export * from './components/ai-elements/message'
+export * from './components/ai-elements/prompt-input'
+export * from './components/ai-elements/reasoning'
+export * from './components/ai-elements/response'
+export * from './components/ai-elements/shimmer'
+export * from './components/ui/alert'
+export * from './components/ui/button'
+export * from './components/ui/skeleton'
+export * from './components/chat'

+ 37 - 0
libraries/ai/package.json

@@ -0,0 +1,37 @@
+{
+  "name": "@overleaf/ai",
+  "private": true,
+  "version": "1.0.0",
+  "description": "AI UI",
+  "main": "index.ts",
+  "license": "AGPL-3.0-only",
+  "dependencies": {
+    "@ai-sdk/react": "^3.0.0-beta.93",
+    "@radix-ui/react-avatar": "^1.1.10",
+    "@radix-ui/react-collapsible": "^1.1.12",
+    "@radix-ui/react-dropdown-menu": "^2.1.16",
+    "@radix-ui/react-hover-card": "^1.1.15",
+    "@radix-ui/react-select": "^2.2.6",
+    "@radix-ui/react-slot": "^1.2.3",
+    "@radix-ui/react-use-controllable-state": "^1.2.2",
+    "@tailwindcss/container-queries": "^0.1.1",
+    "@valibot/to-json-schema": "^1.3.0",
+    "ai": "^6.0.0-beta.84",
+    "class-variance-authority": "^0.7.1",
+    "clsx": "^2.1.1",
+    "effect": "^3.19.0",
+    "lucide-react": "^0.548.0",
+    "motion": "^12.23.24",
+    "nanoid": "^4.0.2",
+    "react": "^18.3.1",
+    "react-dom": "^18.3.1",
+    "streamdown": "^1.4.0",
+    "tailwind-merge": "^3.3.1",
+    "use-stick-to-bottom": "^1.1.1"
+  },
+  "peerDependencies": {
+    "react": "^18.3.1",
+    "react-dom": "^18.3.1"
+  },
+  "type": "commonjs"
+}

+ 9 - 0
libraries/ai/postcss.config.js

@@ -0,0 +1,9 @@
+const path = require('node:path')
+module.exports = {
+  plugins: {
+    tailwindcss: {
+      config: path.resolve(__dirname, 'tailwind.config.js'),
+    },
+    autoprefixer: {},
+  },
+}

+ 85 - 0
libraries/ai/tailwind.config.js

@@ -0,0 +1,85 @@
+const { dirname } = require('node:path')
+const containerQueries = require('@tailwindcss/container-queries')
+
+module.exports = {
+  darkMode: ['class'],
+  content: [
+    dirname(require.resolve('@overleaf/ai')) + '/tailwind.css',
+    dirname(require.resolve('@overleaf/ai')) + '/components/**/*.tsx',
+    './modules/workbench/**/*.tsx',
+    require.resolve('streamdown'),
+  ],
+  theme: {
+    extend: {
+      colors: {
+        background: 'var(--background)',
+        foreground: 'var(--foreground)',
+        card: {
+          DEFAULT: 'var(--card)',
+          foreground: 'var(--card-foreground)',
+        },
+        primary: {
+          DEFAULT: 'var(--primary)',
+          foreground: 'var(--primary-foreground)',
+        },
+        secondary: {
+          DEFAULT: 'var(--secondary)',
+          foreground: 'var(--secondary-foreground)',
+        },
+        destructive: {
+          DEFAULT: 'var(--destructive)',
+          foreground: 'var(--destructive-foreground)',
+        },
+        muted: {
+          DEFAULT: 'var(--muted)',
+          foreground: 'var(--muted-foreground)',
+        },
+        accent: {
+          DEFAULT: 'var(--accent)',
+          foreground: 'var(--accent-foreground)',
+        },
+        popover: {
+          DEFAULT: 'var(--popover)',
+          foreground: 'var(--popover-foreground)',
+        },
+        border: 'var(--border)',
+        input: 'var(--input)',
+        ring: 'var(--ring)',
+        'chart-1': 'var(--chart-1)',
+        'chart-2': 'var(--chart-2)',
+        'chart-3': 'var(--chart-3)',
+        'chart-4': 'var(--chart-4)',
+        'chart-5': 'var(--chart-5)',
+        sidebar: 'var(--sidebar)',
+        'sidebar-foreground': 'var(--sidebar-foreground)',
+        'sidebar-primary': 'var(--sidebar-primary)',
+        'sidebar-primary-foreground': 'var(--sidebar-primary-foreground)',
+        'sidebar-accent': 'var(--sidebar-accent)',
+        'sidebar-accent-foreground': 'var(--sidebar-accent-foreground)',
+        'sidebar-border': 'var(--sidebar-border)',
+        'sidebar-ring': 'var(--sidebar-ring)',
+      },
+      borderRadius: {
+        sm: 'calc(var(--radius) - 4px)',
+        md: 'calc(var(--radius) - 2px)',
+        lg: 'var(--radius)',
+        xl: 'calc(var(--radius) + 4px)',
+      },
+      keyframes: {
+        'accordion-down': {
+          from: { height: 0 },
+          to: { height: 'var(--radix-accordion-content-height)' },
+        },
+        'accordion-up': {
+          from: { height: 'var(--radix-accordion-content-height)' },
+          to: { height: 0 },
+        },
+      },
+      animation: {
+        'accordion-down': 'accordion-down 0.2s ease-out',
+        'accordion-up': 'accordion-up 0.2s ease-out',
+      },
+    },
+  },
+  plugins: [containerQueries],
+}

+ 157 - 0
libraries/ai/tailwind.css

@@ -0,0 +1,157 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+@custom-variant dark (&:is(.dark *));
+
+:host {
+  --background: oklch(100% 0 0deg);
+  --foreground: oklch(14.5% 0 0deg);
+  --card: oklch(100% 0 0deg);
+  --card-foreground: oklch(14.5% 0 0deg);
+  --popover: oklch(100% 0 0deg);
+  --popover-foreground: oklch(14.5% 0 0deg);
+  --primary: oklch(20.5% 0 0deg);
+  --primary-foreground: oklch(98.5% 0 0deg);
+  --secondary: oklch(97% 0 0deg);
+  --secondary-foreground: oklch(20.5% 0 0deg);
+  --muted: oklch(97% 0 0deg);
+  --muted-foreground: oklch(55.6% 0 0deg);
+  --accent: oklch(70.8% 0 0deg);
+  --accent-foreground: oklch(20.5% 0 0deg);
+  --destructive: oklch(57.7% 0.245 27.325deg);
+  --destructive-foreground: oklch(57.7% 0.245 27.325deg);
+  --border: oklch(0.757 0.154 143.872);
+  --input: oklch(92.2% 0 0deg);
+  --ring: oklch(70.8% 0 0deg);
+  --chart-1: oklch(64.6% 0.222 41.116deg);
+  --chart-2: oklch(60% 0.118 184.704deg);
+  --chart-3: oklch(39.8% 0.07 227.392deg);
+  --chart-4: oklch(82.8% 0.189 84.429deg);
+  --chart-5: oklch(76.9% 0.188 70.08deg);
+  --radius: 0.625rem;
+  --sidebar: oklch(98.5% 0 0deg);
+  --sidebar-foreground: oklch(14.5% 0 0deg);
+  --sidebar-primary: oklch(20.5% 0 0deg);
+  --sidebar-primary-foreground: oklch(98.5% 0 0deg);
+  --sidebar-accent: oklch(97% 0 0deg);
+  --sidebar-accent-foreground: oklch(20.5% 0 0deg);
+  --sidebar-border: oklch(92.2% 0 0deg);
+  --sidebar-ring: oklch(70.8% 0 0deg);
+
+  /* @theme inline */
+  --color-background: var(--background);
+  --color-foreground: var(--foreground);
+  --color-card: var(--card);
+  --color-card-foreground: var(--card-foreground);
+  --color-popover: var(--popover);
+  --color-popover-foreground: var(--popover-foreground);
+  --color-primary: var(--primary);
+  --color-primary-foreground: var(--primary-foreground);
+  --color-secondary: var(--secondary);
+  --color-secondary-foreground: var(--secondary-foreground);
+  --color-muted: var(--muted);
+  --color-muted-foreground: var(--muted-foreground);
+  --color-accent: var(--accent);
+  --color-accent-foreground: var(--accent-foreground);
+  --color-destructive: var(--destructive);
+  --color-destructive-foreground: var(--destructive-foreground);
+  --color-border: var(--border);
+  --color-input: var(--input);
+  --color-ring: var(--ring);
+  --color-chart-1: var(--chart-1);
+  --color-chart-2: var(--chart-2);
+  --color-chart-3: var(--chart-3);
+  --color-chart-4: var(--chart-4);
+  --color-chart-5: var(--chart-5);
+  --radius-sm: calc(var(--radius) - 4px);
+  --radius-md: calc(var(--radius) - 2px);
+  --radius-lg: var(--radius);
+  --radius-xl: calc(var(--radius) + 4px);
+  --color-sidebar: var(--sidebar);
+  --color-sidebar-foreground: var(--sidebar-foreground);
+  --color-sidebar-primary: var(--sidebar-primary);
+  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+  --color-sidebar-accent: var(--sidebar-accent);
+  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+  --color-sidebar-border: var(--sidebar-border);
+  --color-sidebar-ring: var(--sidebar-ring);
+}
+
+.dark {
+  --background: oklch(14.5% 0 0deg);
+  --foreground: oklch(98.5% 0 0deg);
+  --card: oklch(14.5% 0 0deg);
+  --card-foreground: oklch(98.5% 0 0deg);
+  --popover: oklch(14.5% 0 0deg);
+  --popover-foreground: oklch(98.5% 0 0deg);
+  --primary: oklch(98.5% 0 0deg);
+  --primary-foreground: oklch(20.5% 0 0deg);
+  --secondary: oklch(26.9% 0 0deg);
+  --secondary-foreground: oklch(98.5% 0 0deg);
+  --muted: oklch(26.9% 0 0deg);
+  --muted-foreground: oklch(70.8% 0 0deg);
+  --accent: oklch(26.9% 0 0deg);
+  --accent-foreground: oklch(70.8% 0 0deg);
+  --destructive: oklch(39.6% 0.141 25.723deg);
+  --destructive-foreground: oklch(63.7% 0.237 25.331deg);
+  --border: oklch(26.9% 0 0deg);
+  --input: oklch(26.9% 0 0deg);
+  --ring: oklch(43.9% 0 0deg);
+  --sidebar: oklch(20.5% 0 0deg);
+  --sidebar-foreground: oklch(98.5% 0 0deg);
+  --sidebar-primary: oklch(48.8% 0.243 264.376deg);
+  --sidebar-primary-foreground: oklch(98.5% 0 0deg);
+  --sidebar-accent: oklch(26.9% 0 0deg);
+  --sidebar-accent-foreground: oklch(98.5% 0 0deg);
+  --sidebar-border: oklch(26.9% 0 0deg);
+  --sidebar-ring: oklch(43.9% 0 0deg);
+}
+
+/* @theme inline {
+  --color-background: var(--background);
+  --color-foreground: var(--foreground);
+  --color-card: var(--card);
+  --color-card-foreground: var(--card-foreground);
+  --color-popover: var(--popover);
+  --color-popover-foreground: var(--popover-foreground);
+  --color-primary: var(--primary);
+  --color-primary-foreground: var(--primary-foreground);
+  --color-secondary: var(--secondary);
+  --color-secondary-foreground: var(--secondary-foreground);
+  --color-muted: var(--muted);
+  --color-muted-foreground: var(--muted-foreground);
+  --color-accent: var(--accent);
+  --color-accent-foreground: var(--accent-foreground);
+  --color-destructive: var(--destructive);
+  --color-destructive-foreground: var(--destructive-foreground);
+  --color-border: var(--border);
+  --color-input: var(--input);
+  --color-ring: var(--ring);
+  --color-chart-1: var(--chart-1);
+  --color-chart-2: var(--chart-2);
+  --color-chart-3: var(--chart-3);
+  --color-chart-4: var(--chart-4);
+  --color-chart-5: var(--chart-5);
+  --radius-sm: calc(var(--radius) - 4px);
+  --radius-md: calc(var(--radius) - 2px);
+  --radius-lg: var(--radius);
+  --radius-xl: calc(var(--radius) + 4px);
+  --color-sidebar: var(--sidebar);
+  --color-sidebar-foreground: var(--sidebar-foreground);
+  --color-sidebar-primary: var(--sidebar-primary);
+  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+  --color-sidebar-accent: var(--sidebar-accent);
+  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+  --color-sidebar-border: var(--sidebar-border);
+  --color-sidebar-ring: var(--sidebar-ring);
+} */
+
+/* @layer base {
+  * {
+    @apply border-border outline-ring/50;
+  }
+
+  body {
+    @apply bg-background text-foreground;
+  }
+} */

+ 12 - 0
libraries/ai/tsconfig.json

@@ -0,0 +1,12 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "ES2022",
+    "moduleResolution": "bundler",
+    "esModuleInterop": true,
+    "forceConsistentCasingInFileNames": true,
+    "strict": true,
+    "skipLibCheck": true,
+    "jsx": "preserve"
+  }
+}

+ 6 - 0
libraries/ai/utils/index.ts

@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from 'clsx'
+import { twMerge } from 'tailwind-merge'
+
+export function cn(...inputs: ClassValue[]) {
+  return twMerge(clsx(inputs))
+}

Diferenças do arquivo suprimidas por serem muito extensas
+ 1682 - 18
package-lock.json


+ 1 - 0
services/web/.jenkinsIncludeFile

@@ -2,6 +2,7 @@
 .eslint*
 .prettier*
 libraries/access-token-encryptor/**
+libraries/ai/**
 libraries/eslint-plugin/**
 libraries/fetch-utils/**
 libraries/logger/**

+ 3 - 0
services/web/Dockerfile

@@ -24,6 +24,7 @@ FROM base AS deps-prod
 
 COPY package.json package-lock.json /overleaf/
 COPY libraries/access-token-encryptor/package.json /overleaf/libraries/access-token-encryptor/package.json
+COPY libraries/ai/package.json /overleaf/libraries/ai/package.json
 COPY libraries/eslint-plugin/package.json /overleaf/libraries/eslint-plugin/package.json
 COPY libraries/fetch-utils/package.json /overleaf/libraries/fetch-utils/package.json
 COPY libraries/logger/package.json /overleaf/libraries/logger/package.json
@@ -58,6 +59,7 @@ FROM deps AS dev
 ARG SENTRY_RELEASE
 ENV SENTRY_RELEASE=$SENTRY_RELEASE
 COPY libraries/access-token-encryptor/ /overleaf/libraries/access-token-encryptor/
+COPY libraries/ai/ /overleaf/libraries/ai/
 COPY libraries/eslint-plugin/ /overleaf/libraries/eslint-plugin/
 COPY libraries/fetch-utils/ /overleaf/libraries/fetch-utils/
 COPY libraries/logger/ /overleaf/libraries/logger/
@@ -95,6 +97,7 @@ RUN nice find /overleaf/services/web/public -name '*.js.map' -delete
 # copy source code and precompile pug images
 FROM deps-prod AS pug
 COPY libraries/access-token-encryptor/ /overleaf/libraries/access-token-encryptor/
+COPY libraries/ai/ /overleaf/libraries/ai/
 COPY libraries/eslint-plugin/ /overleaf/libraries/eslint-plugin/
 COPY libraries/fetch-utils/ /overleaf/libraries/fetch-utils/
 COPY libraries/logger/ /overleaf/libraries/logger/

+ 1 - 0
services/web/Makefile

@@ -570,6 +570,7 @@ IMAGE_SCRATCH ?= $(IMAGE_REPO):do-not-use-this-tag-for-deploys--it-is-used-for-e
 IMAGE_CACHE ?= $(IMAGE_REPO):cache-$(shell cat \
 	$(MONOREPO)/package.json \
 	$(MONOREPO)/package-lock.json \
+	$(MONOREPO)/libraries/ai/package.json \
 	$(MONOREPO)/libraries/access-token-encryptor/package.json \
   $(MONOREPO)/libraries/eslint-plugin/package.json \
   $(MONOREPO)/libraries/fetch-utils/package.json \

+ 1 - 0
services/web/app/src/Features/Project/ProjectController.mjs

@@ -403,6 +403,7 @@ const _ProjectController = {
       'writefull-frontend-migration',
       'chat-edit-delete',
       'compile-timeout-remove-info',
+      'ai-workbench',
       'compile-timeout-target-plans',
     ].filter(Boolean)
 

+ 1 - 0
services/web/app/src/models/UserFeatureUsage.js

@@ -9,6 +9,7 @@ const Usage = new Schema({
 const UserFeatureUsageSchema = new Schema({
   features: {
     aiErrorAssistant: Usage,
+    aiWorkbench: Usage,
   },
 })
 

+ 1 - 0
services/web/config/settings.defaults.js

@@ -1040,6 +1040,7 @@ module.exports = {
     referenceSearchSetting: [],
     errorLogsComponents: [],
     referenceIndices: [],
+    railEntries: [],
   },
 
   moduleImportSequence: [

+ 1 - 0
services/web/frontend/extracted-translations.json

@@ -124,6 +124,7 @@
   "ai_feedback_the_suggestion_didnt_fix_the_error": "",
   "ai_feedback_the_suggestion_wasnt_the_best_fix_available": "",
   "ai_feedback_there_was_no_code_fix_suggested": "",
+  "ai_workbench": "",
   "alignment": "",
   "all_borders": "",
   "all_events": "",

BIN
services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2


+ 1 - 0
services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs

@@ -49,6 +49,7 @@ export default /** @type {const} */ ([
   'shuffle',
   'smart_toy',
   'space_dashboard',
+  'star',
   'strikethrough_s',
   'table_chart',
   'table_chart',

+ 3 - 1
services/web/frontend/js/features/ide-redesign/components/rail/rail-panel.tsx

@@ -52,7 +52,9 @@ export default function RailPanel({
       >
         <Tab.Content className="ide-rail-tab-content">
           {railTabs
-            .filter(({ hide }) => !hide)
+            .filter(({ hide }) => {
+              return typeof hide === 'function' ? !hide() : !hide
+            })
             .map(({ key, component, mountOnFirstLoad }) => (
               <Tab.Pane
                 eventKey={key}

+ 22 - 3
services/web/frontend/js/features/ide-redesign/components/rail/rail.tsx

@@ -26,6 +26,14 @@ import RailResizeHandle from './rail-resize-handle'
 import RailModals from './rail-modals'
 import RailOverflowDropdown from './rail-overflow-dropdown'
 import useRailOverflow from '../../hooks/use-rail-overflow'
+import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
+
+const moduleRailEntries = (
+  importOverleafModules('railEntries') as {
+    import: { default: RailElement }
+    path: string
+  }[]
+).map(({ import: { default: element } }) => element)
 
 export const RailLayout = () => {
   const { sendEvent } = useEditorAnalytics()
@@ -79,6 +87,7 @@ export const RailLayout = () => {
         title: t('chat'),
         hide: !getMeta('ol-capabilities')?.includes('chat'),
       },
+      ...moduleRailEntries,
     ],
     [t, features.trackChangesVisible, view]
   )
@@ -125,7 +134,13 @@ export const RailLayout = () => {
       } else {
         // HACK: Apparently the onSelect event is triggered with href attributes
         // from DropdownItems
-        if (!railTabs.some(tab => !tab.hide && tab.key === key)) {
+        if (
+          !railTabs.some(tab =>
+            typeof tab.hide === 'function'
+              ? !tab.hide()
+              : !tab.hide && tab.key === key
+          )
+        ) {
           // Attempting to open a non-existent tab
           return
         }
@@ -150,7 +165,9 @@ export const RailLayout = () => {
   )
 
   useEffect(() => {
-    const validTabKeys = railTabs.filter(tab => !tab.hide).map(tab => tab.key)
+    const validTabKeys = railTabs
+      .filter(tab => (typeof tab.hide === 'function' ? !tab.hide() : !tab.hide))
+      .map(tab => tab.key)
     if (!validTabKeys.includes(selectedTab) && isOpen) {
       // If the selected tab is no longer valid (e.g. due to permissions changes),
       // switch back to the file tree
@@ -199,7 +216,9 @@ export const RailLayout = () => {
         <Nav activeKey={selectedTab} className="ide-rail-tabs-nav">
           <div className="ide-rail-tabs-wrapper" ref={tabWrapperRef}>
             {tabsInRail
-              .filter(({ hide }) => !hide)
+              .filter(({ hide }) =>
+                typeof hide === 'function' ? !hide() : !hide
+              )
               .map(({ icon, key, indicator, title, disabled }) => (
                 <RailTab
                   open={isOpen && selectedTab === key}

+ 1 - 0
services/web/frontend/js/features/ide-redesign/contexts/rail-context.tsx

@@ -23,6 +23,7 @@ export type RailTabKey =
   | 'review-panel'
   | 'chat'
   | 'full-project-search'
+  | 'workbench'
 
 export type RailModalKey = 'keyboard-shortcuts' | 'contact-us' | 'dictionary'
 

+ 1 - 1
services/web/frontend/js/features/ide-redesign/utils/rail-types.ts

@@ -8,7 +8,7 @@ export type RailElement = {
   component: ReactElement | null
   indicator?: ReactElement
   title: string
-  hide?: boolean
+  hide?: boolean | (() => boolean)
   disabled?: boolean
   mountOnFirstLoad?: boolean
 }

+ 1 - 0
services/web/locales/en.json

@@ -148,6 +148,7 @@
   "ai_feedback_the_suggestion_didnt_fix_the_error": "The suggestion didn’t fix the error",
   "ai_feedback_the_suggestion_wasnt_the_best_fix_available": "The suggestion wasn’t the best fix available",
   "ai_feedback_there_was_no_code_fix_suggested": "There was no code fix suggested",
+  "ai_workbench": "AI workbench",
   "alignment": "Alignment",
   "all": "All",
   "all_borders": "All borders",

+ 4 - 0
services/web/package.json

@@ -81,6 +81,8 @@
     "safari > 14"
   ],
   "dependencies": {
+    "@ai-sdk/mcp": "^1.0.0-beta.13",
+    "@ai-sdk/openai": "^3.0.0-beta.44",
     "@aws-sdk/client-ses": "^3.864.0",
     "@contentful/rich-text-html-renderer": "^16.0.2",
     "@contentful/rich-text-types": "^16.0.2",
@@ -107,6 +109,7 @@
     "@stripe/stripe-js": "^7.7.0",
     "@xmldom/xmldom": "^0.7.13",
     "accepts": "^1.3.7",
+    "ai": "^6.0.0-beta.84",
     "ajv": "^8.12.0",
     "archiver": "^5.3.0",
     "async": "^3.2.5",
@@ -217,6 +220,7 @@
     "@lezer/highlight": "^1.2.1",
     "@lezer/lr": "^1.4.2",
     "@lezer/markdown": "^1.4.3",
+    "@overleaf/ai": "^1.0.0",
     "@overleaf/codemirror-tree-view": "^0.1.3",
     "@overleaf/dictionaries": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.3.tar.gz",
     "@overleaf/eslint-plugin": "*",

+ 1 - 0
services/web/tsconfig.json

@@ -16,6 +16,7 @@
     "emitDecoratorMetadata": true /* Allow decorators in writefull - inversify */,
     "paths": {
       "@/*": ["./frontend/js/*"],
+      "@modules/*": ["./modules/*"],
       "@overleaf/o-error": ["../../libraries/o-error"],
       "@overleaf/ranges-tracker": ["../../libraries/ranges-tracker"],
       /* can't make this entry @types because that conflicts with the "types" entry below */

+ 22 - 0
services/web/webpack.config.js

@@ -10,6 +10,7 @@ const {
 
 const PackageVersions = require('./app/src/infrastructure/PackageVersions.js')
 const invalidateBabelCacheIfNeeded = require('./frontend/macros/invalidate-babel-cache-if-needed')
+const { dirname } = require('node:path')
 
 // Make sure that babel-macros are re-evaluated after changing the modules config
 invalidateBabelCacheIfNeeded()
@@ -260,6 +261,26 @@ module.exports = {
               },
             ],
           },
+          {
+            // CSS from AI module
+            include: dirname(require.resolve('@overleaf/ai')),
+            use: [
+              {
+                loader: 'css-loader',
+                options: {
+                  exportType: 'css-style-sheet',
+                },
+              },
+              {
+                loader: 'postcss-loader',
+                options: {
+                  postcssOptions: {
+                    config: require.resolve('@overleaf/ai/postcss.config.js'),
+                  },
+                },
+              },
+            ],
+          },
           {
             // Standard CSS processing (extracted into separate file)
             use: [MiniCssExtractPlugin.loader, 'css-loader'],
@@ -309,6 +330,7 @@ module.exports = {
     alias: {
       // custom prefixes for import paths
       '@': path.resolve(__dirname, './frontend/js/'),
+      '@modules': path.resolve(__dirname, './modules/'),
       '@ol-types': path.resolve(__dirname, './types/'),
       '@wf': path.resolve(
         __dirname,

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff