diff --git a/app/layout.tsx b/app/layout.tsx index 13e1bb9..201bc87 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import "katex/dist/katex.min.css"; import "./globals.css"; +import { AppShell, SidebarProvider } from "@/components/sidebar"; export const metadata: Metadata = { title: "Learning Harness", @@ -10,7 +11,11 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - {children} + + + {children} + + ); } diff --git a/components/ai-elements/prompt-input.tsx b/components/ai-elements/prompt-input.tsx deleted file mode 100644 index 5be6019..0000000 --- a/components/ai-elements/prompt-input.tsx +++ /dev/null @@ -1,1463 +0,0 @@ -"use client"; - -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from "@/components/ui/command"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; -import { - InputGroup, - InputGroupAddon, - InputGroupButton, - InputGroupTextarea, -} from "@/components/ui/input-group"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Spinner } from "@/components/ui/spinner"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; -import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai"; -import { - CornerDownLeftIcon, - ImageIcon, - Monitor, - PlusIcon, - SquareIcon, - XIcon, -} from "lucide-react"; -import { nanoid } from "nanoid"; -import type { - ChangeEvent, - ChangeEventHandler, - ClipboardEventHandler, - ComponentProps, - FormEvent, - FormEventHandler, - HTMLAttributes, - KeyboardEventHandler, - PropsWithChildren, - ReactNode, - RefObject, -} from "react"; -import { - Children, - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; - -// ============================================================================ -// Helpers -// ============================================================================ - -const convertBlobUrlToDataUrl = async (url: string): Promise => { - try { - const response = await fetch(url); - const blob = await response.blob(); - // FileReader uses callback-based API, wrapping in Promise is necessary - // oxlint-disable-next-line eslint-plugin-promise(avoid-new) - return new Promise((resolve) => { - const reader = new FileReader(); - // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener) - reader.onloadend = () => resolve(reader.result as string); - // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener) - reader.onerror = () => resolve(null); - reader.readAsDataURL(blob); - }); - } catch { - return null; - } -}; - -const captureScreenshot = async (): Promise => { - if ( - typeof navigator === "undefined" || - !navigator.mediaDevices?.getDisplayMedia - ) { - return null; - } - - let stream: MediaStream | null = null; - const video = document.createElement("video"); - video.muted = true; - video.playsInline = true; - - try { - stream = await navigator.mediaDevices.getDisplayMedia({ - audio: false, - video: true, - }); - - video.srcObject = stream; - - // Video element uses callback-based API, wrapping in Promise is necessary - // oxlint-disable-next-line eslint-plugin-promise(avoid-new) - await new Promise((resolve, reject) => { - // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener) - video.onloadedmetadata = () => resolve(); - // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener) - video.onerror = () => reject(new Error("Failed to load screen stream")); - }); - - await video.play(); - - const width = video.videoWidth; - const height = video.videoHeight; - if (!width || !height) { - return null; - } - - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const context = canvas.getContext("2d"); - if (!context) { - return null; - } - - context.drawImage(video, 0, 0, width, height); - // canvas.toBlob uses callback-based API, wrapping in Promise is necessary - // oxlint-disable-next-line eslint-plugin-promise(avoid-new) - const blob = await new Promise((resolve) => { - canvas.toBlob(resolve, "image/png"); - }); - if (!blob) { - return null; - } - - const timestamp = new Date() - .toISOString() - .replaceAll(/[:.]/g, "-") - .replace("T", "_") - .replace("Z", ""); - - return new File([blob], `screenshot-${timestamp}.png`, { - lastModified: Date.now(), - type: "image/png", - }); - } finally { - if (stream) { - for (const track of stream.getTracks()) { - track.stop(); - } - } - video.pause(); - video.srcObject = null; - } -}; - -// ============================================================================ -// Provider Context & Types -// ============================================================================ - -export interface AttachmentsContext { - files: (FileUIPart & { id: string })[]; - add: (files: File[] | FileList) => void; - remove: (id: string) => void; - clear: () => void; - openFileDialog: () => void; - fileInputRef: RefObject; -} - -export interface TextInputContext { - value: string; - setInput: (v: string) => void; - clear: () => void; -} - -export interface PromptInputControllerProps { - textInput: TextInputContext; - attachments: AttachmentsContext; - /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ - __registerFileInput: ( - ref: RefObject, - open: () => void - ) => void; -} - -const PromptInputController = createContext( - null -); -const ProviderAttachmentsContext = createContext( - null -); - -export const usePromptInputController = () => { - const ctx = useContext(PromptInputController); - if (!ctx) { - throw new Error( - "Wrap your component inside to use usePromptInputController()." - ); - } - return ctx; -}; - -// Optional variants (do NOT throw). Useful for dual-mode components. -const useOptionalPromptInputController = () => - useContext(PromptInputController); - -export const useProviderAttachments = () => { - const ctx = useContext(ProviderAttachmentsContext); - if (!ctx) { - throw new Error( - "Wrap your component inside to use useProviderAttachments()." - ); - } - return ctx; -}; - -const useOptionalProviderAttachments = () => - useContext(ProviderAttachmentsContext); - -export type PromptInputProviderProps = PropsWithChildren<{ - initialInput?: string; -}>; - -/** - * Optional global provider that lifts PromptInput state outside of PromptInput. - * If you don't use it, PromptInput stays fully self-managed. - */ -export const PromptInputProvider = ({ - initialInput: initialTextInput = "", - children, -}: PromptInputProviderProps) => { - // ----- textInput state - const [textInput, setTextInput] = useState(initialTextInput); - const clearInput = useCallback(() => setTextInput(""), []); - - // ----- attachments state (global when wrapped) - const [attachmentFiles, setAttachmentFiles] = useState< - (FileUIPart & { id: string })[] - >([]); - const fileInputRef = useRef(null); - // oxlint-disable-next-line eslint(no-empty-function) - const openRef = useRef<() => void>(() => {}); - - const add = useCallback((files: File[] | FileList) => { - const incoming = [...files]; - if (incoming.length === 0) { - return; - } - - setAttachmentFiles((prev) => [ - ...prev, - ...incoming.map((file) => ({ - filename: file.name, - id: nanoid(), - mediaType: file.type, - type: "file" as const, - url: URL.createObjectURL(file), - })), - ]); - }, []); - - const remove = useCallback((id: string) => { - setAttachmentFiles((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(() => { - setAttachmentFiles((prev) => { - for (const f of prev) { - if (f.url) { - URL.revokeObjectURL(f.url); - } - } - return []; - }); - }, []); - - // Keep a ref to attachments for cleanup on unmount (avoids stale closure) - const attachmentsRef = useRef(attachmentFiles); - - useEffect(() => { - attachmentsRef.current = attachmentFiles; - }, [attachmentFiles]); - - // Cleanup blob URLs on unmount to prevent memory leaks - useEffect( - () => () => { - for (const f of attachmentsRef.current) { - if (f.url) { - URL.revokeObjectURL(f.url); - } - } - }, - [] - ); - - const openFileDialog = useCallback(() => { - openRef.current?.(); - }, []); - - const attachments = useMemo( - () => ({ - add, - clear, - fileInputRef, - files: attachmentFiles, - openFileDialog, - remove, - }), - [attachmentFiles, add, remove, clear, openFileDialog] - ); - - const __registerFileInput = useCallback( - (ref: RefObject, open: () => void) => { - fileInputRef.current = ref.current; - openRef.current = open; - }, - [] - ); - - const controller = useMemo( - () => ({ - __registerFileInput, - attachments, - textInput: { - clear: clearInput, - setInput: setTextInput, - value: textInput, - }, - }), - [textInput, clearInput, attachments, __registerFileInput] - ); - - return ( - - - {children} - - - ); -}; - -// ============================================================================ -// Component Context & Hooks -// ============================================================================ - -const LocalAttachmentsContext = createContext(null); - -export const usePromptInputAttachments = () => { - // Prefer local context (inside PromptInput) as it has validation, fall back to provider - const provider = useOptionalProviderAttachments(); - const local = useContext(LocalAttachmentsContext); - const context = local ?? provider; - if (!context) { - throw new Error( - "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider" - ); - } - return context; -}; - -// ============================================================================ -// Referenced Sources (Local to PromptInput) -// ============================================================================ - -export interface ReferencedSourcesContext { - sources: (SourceDocumentUIPart & { id: string })[]; - add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void; - remove: (id: string) => void; - clear: () => void; -} - -export const LocalReferencedSourcesContext = - createContext(null); - -export const usePromptInputReferencedSources = () => { - const ctx = useContext(LocalReferencedSourcesContext); - if (!ctx) { - throw new Error( - "usePromptInputReferencedSources must be used within a LocalReferencedSourcesContext.Provider" - ); - } - return ctx; -}; - -export type PromptInputActionAddAttachmentsProps = ComponentProps< - typeof DropdownMenuItem -> & { - label?: string; -}; - -export const PromptInputActionAddAttachments = ({ - label = "Add photos or files", - ...props -}: PromptInputActionAddAttachmentsProps) => { - const attachments = usePromptInputAttachments(); - - const handleSelect = useCallback( - (e: Event) => { - e.preventDefault(); - attachments.openFileDialog(); - }, - [attachments] - ); - - return ( - - {label} - - ); -}; - -export type PromptInputActionAddScreenshotProps = ComponentProps< - typeof DropdownMenuItem -> & { - label?: string; -}; - -export const PromptInputActionAddScreenshot = ({ - label = "Take screenshot", - onSelect, - ...props -}: PromptInputActionAddScreenshotProps) => { - const attachments = usePromptInputAttachments(); - - const handleSelect = useCallback( - async (event: Event) => { - onSelect?.(event); - if (event.defaultPrevented) { - return; - } - - try { - const screenshot = await captureScreenshot(); - if (screenshot) { - attachments.add([screenshot]); - } - } catch (error) { - if ( - error instanceof DOMException && - (error.name === "NotAllowedError" || error.name === "AbortError") - ) { - return; - } - throw error; - } - }, - [onSelect, attachments] - ); - - return ( - - - {label} - - ); -}; - -export interface PromptInputMessage { - text: string; - files: FileUIPart[]; -} - -export type PromptInputProps = Omit< - HTMLAttributes, - "onSubmit" | "onError" -> & { - // e.g., "image/*" or leave undefined for any - accept?: string; - multiple?: boolean; - // When true, accepts drops anywhere on document. Default false (opt-in). - globalDrop?: boolean; - // Render a hidden input with given name and keep it in sync for native form posts. Default false. - syncHiddenInput?: boolean; - // Minimal constraints - maxFiles?: number; - // bytes - maxFileSize?: number; - onError?: (err: { - code: "max_files" | "max_file_size" | "accept"; - message: string; - }) => void; - onSubmit: ( - message: PromptInputMessage, - event: FormEvent - ) => void | Promise; -}; - -export const PromptInput = ({ - className, - accept, - multiple, - globalDrop, - syncHiddenInput, - maxFiles, - maxFileSize, - onError, - onSubmit, - children, - ...props -}: PromptInputProps) => { - // Try to use a provider controller if present - const controller = useOptionalPromptInputController(); - const usingProvider = !!controller; - - // Refs - const inputRef = useRef(null); - const formRef = useRef(null); - - // ----- Local attachments (only used when no provider) - const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); - const files = usingProvider ? controller.attachments.files : items; - - // ----- Local referenced sources (always local to PromptInput) - const [referencedSources, setReferencedSources] = useState< - (SourceDocumentUIPart & { id: string })[] - >([]); - - // Keep a ref to files for cleanup on unmount (avoids stale closure) - const filesRef = useRef(files); - - useEffect(() => { - filesRef.current = files; - }, [files]); - - const openFileDialogLocal = useCallback(() => { - inputRef.current?.click(); - }, []); - - const matchesAccept = useCallback( - (f: File) => { - if (!accept || accept.trim() === "") { - return true; - } - - const patterns = accept - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - - return patterns.some((pattern) => { - if (pattern.endsWith("/*")) { - // e.g: image/* -> image/ - const prefix = pattern.slice(0, -1); - return f.type.startsWith(prefix); - } - return f.type === pattern; - }); - }, - [accept] - ); - - const addLocal = useCallback( - (fileList: File[] | FileList) => { - const incoming = [...fileList]; - const accepted = incoming.filter((f) => matchesAccept(f)); - if (incoming.length && accepted.length === 0) { - onError?.({ - code: "accept", - message: "No files match the accepted types.", - }); - return; - } - const withinSize = (f: File) => - maxFileSize ? f.size <= maxFileSize : true; - const sized = accepted.filter(withinSize); - if (accepted.length > 0 && sized.length === 0) { - onError?.({ - code: "max_file_size", - message: "All files exceed the maximum size.", - }); - return; - } - - 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({ - filename: file.name, - id: nanoid(), - mediaType: file.type, - type: "file", - url: URL.createObjectURL(file), - }); - } - return [...prev, ...next]; - }); - }, - [matchesAccept, maxFiles, maxFileSize, onError] - ); - - const removeLocal = useCallback( - (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); - }), - [] - ); - - // Wrapper that validates files before calling provider's add - const addWithProviderValidation = useCallback( - (fileList: File[] | FileList) => { - const incoming = [...fileList]; - const accepted = incoming.filter((f) => matchesAccept(f)); - if (incoming.length && accepted.length === 0) { - onError?.({ - code: "accept", - message: "No files match the accepted types.", - }); - return; - } - const withinSize = (f: File) => - maxFileSize ? f.size <= maxFileSize : true; - const sized = accepted.filter(withinSize); - if (accepted.length > 0 && sized.length === 0) { - onError?.({ - code: "max_file_size", - message: "All files exceed the maximum size.", - }); - return; - } - - const currentCount = files.length; - const capacity = - typeof maxFiles === "number" - ? Math.max(0, maxFiles - currentCount) - : undefined; - const capped = - typeof capacity === "number" ? sized.slice(0, capacity) : sized; - if (typeof capacity === "number" && sized.length > capacity) { - onError?.({ - code: "max_files", - message: "Too many files. Some were not added.", - }); - } - - if (capped.length > 0) { - controller?.attachments.add(capped); - } - }, - [matchesAccept, maxFileSize, maxFiles, onError, files.length, controller] - ); - - const clearAttachments = useCallback( - () => - usingProvider - ? controller?.attachments.clear() - : setItems((prev) => { - for (const file of prev) { - if (file.url) { - URL.revokeObjectURL(file.url); - } - } - return []; - }), - [usingProvider, controller] - ); - - const clearReferencedSources = useCallback( - () => setReferencedSources([]), - [] - ); - - const add = usingProvider ? addWithProviderValidation : addLocal; - const remove = usingProvider ? controller.attachments.remove : removeLocal; - const openFileDialog = usingProvider - ? controller.attachments.openFileDialog - : openFileDialogLocal; - - const clear = useCallback(() => { - clearAttachments(); - clearReferencedSources(); - }, [clearAttachments, clearReferencedSources]); - - // Let provider know about our hidden file input so external menus can call openFileDialog() - useEffect(() => { - if (!usingProvider) { - return; - } - controller.__registerFileInput(inputRef, () => inputRef.current?.click()); - }, [usingProvider, controller]); - - // 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; - } - if (globalDrop) { - // when global drop is on, let the document-level handler own drops - 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, globalDrop]); - - 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 filesRef.current) { - if (f.url) { - URL.revokeObjectURL(f.url); - } - } - } - }, - [usingProvider] - ); - - const handleChange: ChangeEventHandler = useCallback( - (event) => { - if (event.currentTarget.files) { - add(event.currentTarget.files); - } - // Reset input value to allow selecting files that were previously removed - event.currentTarget.value = ""; - }, - [add] - ); - - const attachmentsCtx = useMemo( - () => ({ - add, - clear: clearAttachments, - fileInputRef: inputRef, - files: files.map((item) => ({ ...item, id: item.id })), - openFileDialog, - remove, - }), - [files, add, remove, clearAttachments, openFileDialog] - ); - - const refsCtx = useMemo( - () => ({ - add: (incoming: SourceDocumentUIPart[] | SourceDocumentUIPart) => { - const array = Array.isArray(incoming) ? incoming : [incoming]; - setReferencedSources((prev) => [ - ...prev, - ...array.map((s) => ({ ...s, id: nanoid() })), - ]); - }, - clear: clearReferencedSources, - remove: (id: string) => { - setReferencedSources((prev) => prev.filter((s) => s.id !== id)); - }, - sources: referencedSources, - }), - [referencedSources, clearReferencedSources] - ); - - const handleSubmit: FormEventHandler = useCallback( - async (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(); - } - - try { - // Convert blob URLs to data URLs asynchronously - const convertedFiles: FileUIPart[] = await Promise.all( - files.map(async ({ id: _id, ...item }) => { - if (item.url?.startsWith("blob:")) { - const dataUrl = await convertBlobUrlToDataUrl(item.url); - // If conversion failed, keep the original blob URL - return { - ...item, - url: dataUrl ?? item.url, - }; - } - return item; - }) - ); - - const result = onSubmit({ files: convertedFiles, text }, event); - - // Handle both sync and async onSubmit - if (result instanceof Promise) { - try { - await result; - clear(); - if (usingProvider) { - controller.textInput.clear(); - } - } catch { - // Don't clear on error - user may want to retry - } - } else { - // Sync function completed without throwing, clear inputs - clear(); - if (usingProvider) { - controller.textInput.clear(); - } - } - } catch { - // Don't clear on error - user may want to retry - } - }, - [usingProvider, controller, files, onSubmit, clear] - ); - - // Render with or without local provider - const inner = ( - <> - -
- {children} -
- - ); - - const withReferencedSources = ( - - {inner} - - ); - - // Always provide LocalAttachmentsContext so children get validated add function - return ( - - {withReferencedSources} - - ); -}; - -export type PromptInputBodyProps = HTMLAttributes; - -export const PromptInputBody = ({ - className, - ...props -}: PromptInputBodyProps) => ( -
-); - -export type PromptInputTextareaProps = ComponentProps< - typeof InputGroupTextarea ->; - -export const PromptInputTextarea = ({ - onChange, - onKeyDown, - className, - placeholder = "What would you like to know?", - ...props -}: PromptInputTextareaProps) => { - const controller = useOptionalPromptInputController(); - const attachments = usePromptInputAttachments(); - const [isComposing, setIsComposing] = useState(false); - - const handleKeyDown: KeyboardEventHandler = useCallback( - (e) => { - // Call the external onKeyDown handler first - onKeyDown?.(e); - - // If the external handler prevented default, don't run internal logic - if (e.defaultPrevented) { - return; - } - - if (e.key === "Enter") { - if (isComposing || e.nativeEvent.isComposing) { - return; - } - if (e.shiftKey) { - return; - } - e.preventDefault(); - - // Check if the submit button is disabled before submitting - const { form } = e.currentTarget; - const submitButton = form?.querySelector( - 'button[type="submit"]' - ) as HTMLButtonElement | null; - if (submitButton?.disabled) { - return; - } - - 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); - } - } - }, - [onKeyDown, isComposing, attachments] - ); - - const handlePaste: ClipboardEventHandler = useCallback( - (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); - } - }, - [attachments] - ); - - const handleCompositionEnd = useCallback(() => setIsComposing(false), []); - const handleCompositionStart = useCallback(() => setIsComposing(true), []); - - const controlledProps = controller - ? { - onChange: (e: ChangeEvent) => { - controller.textInput.setInput(e.currentTarget.value); - onChange?.(e); - }, - value: controller.textInput.value, - } - : { - onChange, - }; - - return ( - - ); -}; - -export type PromptInputHeaderProps = Omit< - ComponentProps, - "align" ->; - -export const PromptInputHeader = ({ - className, - ...props -}: PromptInputHeaderProps) => ( - -); - -export type PromptInputFooterProps = Omit< - ComponentProps, - "align" ->; - -export const PromptInputFooter = ({ - className, - ...props -}: PromptInputFooterProps) => ( - -); - -export type PromptInputToolsProps = HTMLAttributes; - -export const PromptInputTools = ({ - className, - ...props -}: PromptInputToolsProps) => ( -
-); - -export type PromptInputButtonTooltip = - | string - | { - content: ReactNode; - shortcut?: string; - side?: ComponentProps["side"]; - }; - -export type PromptInputButtonProps = ComponentProps & { - tooltip?: PromptInputButtonTooltip; -}; - -export const PromptInputButton = ({ - variant = "ghost", - className, - size, - tooltip, - ...props -}: PromptInputButtonProps) => { - const newSize = - size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm"); - - const button = ( - - ); - - if (!tooltip) { - return button; - } - - const tooltipContent = - typeof tooltip === "string" ? tooltip : tooltip.content; - const shortcut = typeof tooltip === "string" ? undefined : tooltip.shortcut; - const side = typeof tooltip === "string" ? "top" : (tooltip.side ?? "top"); - - return ( - - {button} - - {tooltipContent} - {shortcut && ( - {shortcut} - )} - - - ); -}; - -export type PromptInputActionMenuProps = ComponentProps; -export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => ( - -); - -export type PromptInputActionMenuTriggerProps = PromptInputButtonProps; - -export const PromptInputActionMenuTrigger = ({ - className, - children, - ...props -}: PromptInputActionMenuTriggerProps) => ( - - - {children ?? } - - -); - -export type PromptInputActionMenuContentProps = ComponentProps< - typeof DropdownMenuContent ->; -export const PromptInputActionMenuContent = ({ - className, - ...props -}: PromptInputActionMenuContentProps) => ( - -); - -export type PromptInputActionMenuItemProps = ComponentProps< - typeof DropdownMenuItem ->; -export const PromptInputActionMenuItem = ({ - className, - ...props -}: PromptInputActionMenuItemProps) => ( - -); - -// 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 & { - status?: ChatStatus; - onStop?: () => void; -}; - -export const PromptInputSubmit = ({ - className, - variant = "default", - size = "icon-sm", - status, - onStop, - onClick, - children, - ...props -}: PromptInputSubmitProps) => { - const isGenerating = status === "submitted" || status === "streaming"; - - let Icon = ; - - if (status === "submitted") { - Icon = ; - } else if (status === "streaming") { - Icon = ; - } else if (status === "error") { - Icon = ; - } - - const handleClick = useCallback( - (e: React.MouseEvent) => { - if (isGenerating && onStop) { - e.preventDefault(); - onStop(); - return; - } - onClick?.(e); - }, - [isGenerating, onStop, onClick] - ); - - return ( - - {children ?? Icon} - - ); -}; - -export type PromptInputSelectProps = ComponentProps; - -export const PromptInputSelect = (props: PromptInputSelectProps) => ( -