diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/animated-placeholder-effect.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/animated-placeholder-effect.tsx new file mode 100644 index 0000000000..857ed1d4e0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/animated-placeholder-effect.tsx @@ -0,0 +1,25 @@ +'use client' + +import { useEffect } from 'react' +import { useAnimatedPlaceholder } from '@/app/workspace/[workspaceId]/home/hooks' + +interface AnimatedPlaceholderEffectProps { + textareaRef: React.RefObject + isInitialView: boolean +} + +export function AnimatedPlaceholderEffect({ + textareaRef, + isInitialView, +}: AnimatedPlaceholderEffectProps) { + const animatedPlaceholder = useAnimatedPlaceholder(isInitialView) + const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim' + + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.placeholder = placeholder + } + }, [placeholder, textareaRef]) + + return null +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/attached-files-list.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/attached-files-list.tsx new file mode 100644 index 0000000000..3e00e43cf6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/attached-files-list.tsx @@ -0,0 +1,77 @@ +'use client' + +import React from 'react' +import { Loader2, X } from 'lucide-react' +import { Tooltip } from '@/components/emcn' +import { getDocumentIcon } from '@/components/icons/document-icons' +import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' + +interface AttachedFilesListProps { + attachedFiles: AttachedFile[] + onFileClick: (file: AttachedFile) => void + onRemoveFile: (id: string) => void +} + +export const AttachedFilesList = React.memo(function AttachedFilesList({ + attachedFiles, + onFileClick, + onRemoveFile, +}: AttachedFilesListProps) { + if (attachedFiles.length === 0) return null + + return ( +
+ {attachedFiles.map((file) => { + const isImage = file.type.startsWith('image/') + return ( + + +
onFileClick(file)} + > + {isImage && file.previewUrl ? ( + {file.name} + ) : ( +
+ {(() => { + const Icon = getDocumentIcon(file.type, file.name) + return + })()} + + {file.name.split('.').pop()} + +
+ )} + {file.uploading && ( +
+ +
+ )} + {!file.uploading && ( + + )} +
+
+ +

{file.name}

+
+
+ ) + })} +
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/constants.ts new file mode 100644 index 0000000000..3c0f5971d6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/constants.ts @@ -0,0 +1,93 @@ +import { cn } from '@/lib/core/utils/cn' +import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' +import type { ChatContext } from '@/stores/panel' + +export interface SpeechRecognitionEvent extends Event { + resultIndex: number + results: SpeechRecognitionResultList +} + +export interface SpeechRecognitionErrorEvent extends Event { + error: string +} + +export interface SpeechRecognitionInstance extends EventTarget { + continuous: boolean + interimResults: boolean + lang: string + start(): void + stop(): void + abort(): void + onstart: ((ev: Event) => void) | null + onend: ((ev: Event) => void) | null + onresult: ((ev: SpeechRecognitionEvent) => void) | null + onerror: ((ev: SpeechRecognitionErrorEvent) => void) | null +} + +export interface SpeechRecognitionStatic { + new (): SpeechRecognitionInstance +} + +export type WindowWithSpeech = Window & { + SpeechRecognition?: SpeechRecognitionStatic + webkitSpeechRecognition?: SpeechRecognitionStatic +} + +export interface PlusMenuHandle { + open: () => void +} + +export const TEXTAREA_BASE_CLASSES = cn( + 'm-0 box-border h-auto min-h-[24px] w-full resize-none', + 'overflow-y-auto overflow-x-hidden break-all border-0 bg-transparent', + 'px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em]', + 'text-transparent caret-[var(--text-primary)] outline-none', + 'placeholder:font-[380] placeholder:text-[var(--text-subtle)]', + 'focus-visible:ring-0 focus-visible:ring-offset-0', + '[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' +) + +export const OVERLAY_CLASSES = cn( + 'pointer-events-none absolute top-0 left-0 m-0 box-border h-auto w-full resize-none', + 'overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all border-0 bg-transparent', + 'px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em]', + 'text-[var(--text-primary)] outline-none', + '[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' +) + +export const SEND_BUTTON_BASE = 'h-[28px] w-[28px] rounded-full border-0 p-0 transition-colors' +export const SEND_BUTTON_ACTIVE = + 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]' +export const SEND_BUTTON_DISABLED = 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]' + +export const MAX_CHAT_TEXTAREA_HEIGHT = 200 +export const SPEECH_RECOGNITION_LANG = 'en-US' + +export function autoResizeTextarea(e: React.FormEvent, maxHeight: number) { + const target = e.target as HTMLTextAreaElement + target.style.height = 'auto' + target.style.height = `${Math.min(target.scrollHeight, maxHeight)}px` +} + +export function mapResourceToContext(resource: MothershipResource): ChatContext { + switch (resource.type) { + case 'workflow': + return { + kind: 'workflow', + workflowId: resource.id, + label: resource.title, + } + case 'knowledgebase': + return { + kind: 'knowledge', + knowledgeId: resource.id, + label: resource.title, + } + case 'table': + return { kind: 'table', tableId: resource.id, label: resource.title } + case 'file': + return { kind: 'file', fileId: resource.id, label: resource.title } + default: + return { kind: 'docs', label: resource.title } + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/drop-overlay.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/drop-overlay.tsx new file mode 100644 index 0000000000..5d0af330c4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/drop-overlay.tsx @@ -0,0 +1,41 @@ +'use client' + +import React from 'react' +import { + AudioIcon, + CsvIcon, + DocxIcon, + JsonIcon, + MarkdownIcon, + PdfIcon, + TxtIcon, + VideoIcon, + XlsxIcon, +} from '@/components/icons/document-icons' + +const DROP_OVERLAY_ICONS = [ + PdfIcon, + DocxIcon, + XlsxIcon, + CsvIcon, + TxtIcon, + MarkdownIcon, + JsonIcon, + AudioIcon, + VideoIcon, +] as const + +export const DropOverlay = React.memo(function DropOverlay() { + return ( +
+
+ Drop files +
+ {DROP_OVERLAY_ICONS.map((Icon, i) => ( + + ))} +
+
+
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/index.ts new file mode 100644 index 0000000000..a0e71aee02 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/index.ts @@ -0,0 +1,22 @@ +export { AnimatedPlaceholderEffect } from './animated-placeholder-effect' +export { AttachedFilesList } from './attached-files-list' +export type { + PlusMenuHandle, + SpeechRecognitionErrorEvent, + SpeechRecognitionEvent, + SpeechRecognitionInstance, + WindowWithSpeech, +} from './constants' +export { + autoResizeTextarea, + MAX_CHAT_TEXTAREA_HEIGHT, + mapResourceToContext, + OVERLAY_CLASSES, + SPEECH_RECOGNITION_LANG, + TEXTAREA_BASE_CLASSES, +} from './constants' +export { DropOverlay } from './drop-overlay' +export { MicButton } from './mic-button' +export type { AvailableResourceGroup } from './plus-menu-dropdown' +export { PlusMenuDropdown } from './plus-menu-dropdown' +export { SendButton } from './send-button' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/mic-button.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/mic-button.tsx new file mode 100644 index 0000000000..9053013d43 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/mic-button.tsx @@ -0,0 +1,28 @@ +'use client' + +import React from 'react' +import { Mic } from 'lucide-react' +import { cn } from '@/lib/core/utils/cn' + +interface MicButtonProps { + isListening: boolean + onToggle: () => void +} + +export const MicButton = React.memo(function MicButton({ isListening, onToggle }: MicButtonProps) { + return ( + + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/plus-menu-dropdown.tsx new file mode 100644 index 0000000000..4e057a04c5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/plus-menu-dropdown.tsx @@ -0,0 +1,251 @@ +'use client' + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Paperclip } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSearchInput, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@/components/emcn' +import { Plus, Sim } from '@/components/emcn/icons' +import { cn } from '@/lib/core/utils/cn' +import type { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' +import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' +import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/_components/constants' +import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' + +export type AvailableResourceGroup = ReturnType[number] + +interface PlusMenuDropdownProps { + availableResources: AvailableResourceGroup[] + onResourceSelect: (resource: MothershipResource) => void + onFileSelect: () => void + onClose: () => void + textareaRef: React.RefObject + pendingCursorRef: React.MutableRefObject +} + +export const PlusMenuDropdown = React.memo( + React.forwardRef(function PlusMenuDropdown( + { availableResources, onResourceSelect, onFileSelect, onClose, textareaRef, pendingCursorRef }, + ref + ) { + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const [activeIndex, setActiveIndex] = useState(0) + const activeIndexRef = useRef(activeIndex) + + useEffect(() => { + activeIndexRef.current = activeIndex + }, [activeIndex]) + + React.useImperativeHandle( + ref, + () => ({ + open: () => { + setOpen(true) + setSearch('') + setActiveIndex(0) + }, + }), + [] + ) + + const filteredItems = useMemo(() => { + const q = search.toLowerCase().trim() + if (!q) return null + return availableResources.flatMap(({ type, items }) => + items.filter((item) => item.name.toLowerCase().includes(q)).map((item) => ({ type, item })) + ) + }, [search, availableResources]) + + const handleSelect = useCallback( + (resource: MothershipResource) => { + onResourceSelect(resource) + setOpen(false) + setSearch('') + setActiveIndex(0) + }, + [onResourceSelect] + ) + + const filteredItemsRef = useRef(filteredItems) + filteredItemsRef.current = filteredItems + + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const items = filteredItemsRef.current + if (!items) return + if (e.key === 'ArrowDown') { + e.preventDefault() + setActiveIndex((prev) => Math.min(prev + 1, items.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIndex((prev) => Math.max(prev - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + const idx = activeIndexRef.current + if (items.length > 0 && items[idx]) { + const { type, item } = items[idx] + handleSelect({ type, id: item.id, title: item.name }) + } + } + }, + [handleSelect] + ) + + const handleOpenChange = useCallback( + (isOpen: boolean) => { + setOpen(isOpen) + if (!isOpen) { + setSearch('') + setActiveIndex(0) + onClose() + } + }, + [onClose] + ) + + const handleCloseAutoFocus = useCallback( + (e: Event) => { + e.preventDefault() + const textarea = textareaRef.current + if (!textarea) return + if (pendingCursorRef.current !== null) { + textarea.setSelectionRange(pendingCursorRef.current, pendingCursorRef.current) + pendingCursorRef.current = null + } + textarea.focus() + }, + [textareaRef, pendingCursorRef] + ) + + return ( + + + + + + { + setSearch(e.target.value) + setActiveIndex(0) + }} + onKeyDown={handleSearchKeyDown} + /> +
+ {filteredItems ? ( + filteredItems.length > 0 ? ( + filteredItems.map(({ type, item }, index) => { + const config = getResourceConfig(type) + return ( + setActiveIndex(index)} + onClick={() => { + handleSelect({ + type, + id: item.id, + title: item.name, + }) + }} + > + {config.renderDropdownItem({ item })} + + {config.label} + + + ) + }) + ) : ( +
+ No results +
+ ) + ) : ( + <> + { + setOpen(false) + onFileSelect() + }} + > + + Attachments + + + + + Workspace + + + {availableResources.map(({ type, items }) => { + if (items.length === 0) return null + const config = getResourceConfig(type) + const Icon = config.icon + return ( + + + {type === 'workflow' ? ( +
+ ) : ( + + )} + {config.label} + + + {items.map((item) => ( + { + handleSelect({ + type, + id: item.id, + title: item.name, + }) + }} + > + {config.renderDropdownItem({ item })} + + ))} + + + ) + })} + + + + )} +
+ + + ) + }) +) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/send-button.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/send-button.tsx new file mode 100644 index 0000000000..74edc7d60d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/send-button.tsx @@ -0,0 +1,52 @@ +'use client' + +import React from 'react' +import { ArrowUp } from 'lucide-react' +import { Button } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import { + SEND_BUTTON_ACTIVE, + SEND_BUTTON_BASE, + SEND_BUTTON_DISABLED, +} from '@/app/workspace/[workspaceId]/home/components/user-input/_components/constants' + +interface SendButtonProps { + isSending: boolean + canSubmit: boolean + onSubmit: () => void + onStopGeneration: () => void +} + +export const SendButton = React.memo(function SendButton({ + isSending, + canSubmit, + onSubmit, + onStopGeneration, +}: SendButtonProps) { + if (isSending) { + return ( + + ) + } + return ( + + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index a69cc08477..3a549351c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -1,69 +1,35 @@ 'use client' -interface SpeechRecognitionEvent extends Event { - resultIndex: number - results: SpeechRecognitionResultList -} - -interface SpeechRecognitionErrorEvent extends Event { - error: string -} - -interface SpeechRecognitionInstance extends EventTarget { - continuous: boolean - interimResults: boolean - lang: string - start(): void - stop(): void - abort(): void - onstart: ((ev: Event) => void) | null - onend: ((ev: Event) => void) | null - onresult: ((ev: SpeechRecognitionEvent) => void) | null - onerror: ((ev: SpeechRecognitionErrorEvent) => void) | null -} - -interface SpeechRecognitionStatic { - new (): SpeechRecognitionInstance -} - -type WindowWithSpeech = Window & { - SpeechRecognition?: SpeechRecognitionStatic - webkitSpeechRecognition?: SpeechRecognitionStatic -} - +import type React from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { ArrowUp, Loader2, Mic, Paperclip, X } from 'lucide-react' import { useParams } from 'next/navigation' -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSearchInput, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, - Tooltip, -} from '@/components/emcn' -import { Database, Plus, Sim, Table as TableIcon } from '@/components/emcn/icons' -import { - AudioIcon, - CsvIcon, - DocxIcon, - getDocumentIcon, - JsonIcon, - MarkdownIcon, - PdfIcon, - TxtIcon, - VideoIcon, - XlsxIcon, -} from '@/components/icons/document-icons' +import { Database, Table as TableIcon } from '@/components/emcn/icons' +import { getDocumentIcon } from '@/components/icons/document-icons' import { useSession } from '@/lib/auth/auth-client' import { cn } from '@/lib/core/utils/cn' import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation' import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' -import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' +import type { + PlusMenuHandle, + SpeechRecognitionErrorEvent, + SpeechRecognitionEvent, + SpeechRecognitionInstance, + WindowWithSpeech, +} from '@/app/workspace/[workspaceId]/home/components/user-input/_components' +import { + AnimatedPlaceholderEffect, + AttachedFilesList, + autoResizeTextarea, + DropOverlay, + MAX_CHAT_TEXTAREA_HEIGHT, + MicButton, + mapResourceToContext, + OVERLAY_CLASSES, + PlusMenuDropdown, + SendButton, + SPEECH_RECOGNITION_LANG, + TEXTAREA_BASE_CLASSES, +} from '@/app/workspace/[workspaceId]/home/components/user-input/_components' import type { FileAttachmentForApi, MothershipResource, @@ -74,80 +40,13 @@ import { useMentionMenu, useMentionTokens, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks' +import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' import { computeMentionHighlightRanges, extractContextTokens, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' import type { ChatContext } from '@/stores/panel' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useAnimatedPlaceholder } from '../../hooks' - -const TEXTAREA_BASE_CLASSES = cn( - 'm-0 box-border h-auto min-h-[24px] w-full resize-none', - 'overflow-y-auto overflow-x-hidden break-all border-0 bg-transparent', - 'px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em]', - 'text-transparent caret-[var(--text-primary)] outline-none', - 'placeholder:font-[380] placeholder:text-[var(--text-subtle)]', - 'focus-visible:ring-0 focus-visible:ring-offset-0', - '[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' -) - -const OVERLAY_CLASSES = cn( - 'pointer-events-none absolute top-0 left-0 m-0 box-border h-auto w-full resize-none', - 'overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all border-0 bg-transparent', - 'px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em]', - 'text-[var(--text-primary)] outline-none', - '[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' -) - -const SEND_BUTTON_BASE = 'h-[28px] w-[28px] rounded-full border-0 p-0 transition-colors' -const SEND_BUTTON_ACTIVE = - 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]' -const SEND_BUTTON_DISABLED = 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]' - -const MAX_CHAT_TEXTAREA_HEIGHT = 200 -const SPEECH_RECOGNITION_LANG = 'en-US' - -const DROP_OVERLAY_ICONS = [ - PdfIcon, - DocxIcon, - XlsxIcon, - CsvIcon, - TxtIcon, - MarkdownIcon, - JsonIcon, - AudioIcon, - VideoIcon, -] as const - -function autoResizeTextarea(e: React.FormEvent, maxHeight: number) { - const target = e.target as HTMLTextAreaElement - target.style.height = 'auto' - target.style.height = `${Math.min(target.scrollHeight, maxHeight)}px` -} - -function mapResourceToContext(resource: MothershipResource): ChatContext { - switch (resource.type) { - case 'workflow': - return { - kind: 'workflow', - workflowId: resource.id, - label: resource.title, - } - case 'knowledgebase': - return { - kind: 'knowledge', - knowledgeId: resource.id, - label: resource.title, - } - case 'table': - return { kind: 'table', tableId: resource.id, label: resource.title } - case 'file': - return { kind: 'file', fileId: resource.id, label: resource.title } - default: - return { kind: 'docs', label: resource.title } - } -} export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types' @@ -181,10 +80,8 @@ export function UserInput({ const { workspaceId } = useParams<{ workspaceId: string }>() const { data: session } = useSession() const [value, setValue] = useState(defaultValue) - const [plusMenuOpen, setPlusMenuOpen] = useState(false) - const [plusMenuSearch, setPlusMenuSearch] = useState('') - const [plusMenuActiveIndex, setPlusMenuActiveIndex] = useState(0) const overlayRef = useRef(null) + const plusMenuRef = useRef(null) const [prevDefaultValue, setPrevDefaultValue] = useState(defaultValue) if (defaultValue && defaultValue !== prevDefaultValue) { @@ -206,9 +103,6 @@ export function UserInput({ if (editValue) onEditValueConsumed?.() }, [editValue, onEditValueConsumed]) - const animatedPlaceholder = useAnimatedPlaceholder(isInitialView) - const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim' - const files = useFileAttachments({ userId: userId || session?.user?.id, workspaceId, @@ -219,12 +113,14 @@ export function UserInput({ const contextManagement = useContextManagement({ message: value }) + const { addContext } = contextManagement + const handleContextAdd = useCallback( (context: ChatContext) => { - contextManagement.addContext(context) + addContext(context) onContextAdd?.(context) }, - [contextManagement, onContextAdd] + [addContext, onContextAdd] ) const existingResourceKeys = useMemo(() => { @@ -240,14 +136,6 @@ export function UserInput({ const availableResources = useAvailableResources(workspaceId, existingResourceKeys) - const filteredPlusMenuItems = useMemo(() => { - const q = plusMenuSearch.toLowerCase().trim() - if (!q) return null - return availableResources.flatMap(({ type, items }) => - items.filter((item) => item.name.toLowerCase().includes(q)).map((item) => ({ type, item })) - ) - }, [plusMenuSearch, availableResources]) - const mentionMenu = useMentionMenu({ message: value, selectedContexts: contextManagement.selectedContexts, @@ -270,6 +158,11 @@ export function UserInput({ const prefixRef = useRef('') const valueRef = useRef(value) + const filesRef = useRef(files) + filesRef.current = files + const contextRef = useRef(contextManagement) + contextRef.current = contextManagement + useEffect(() => { return () => { recognitionRef.current?.abort() @@ -300,13 +193,14 @@ export function UserInput({ (resource: MothershipResource) => { const textarea = textareaRef.current if (textarea) { - const insertAt = atInsertPosRef.current ?? textarea.selectionStart ?? value.length + const currentValue = valueRef.current + const insertAt = atInsertPosRef.current ?? textarea.selectionStart ?? currentValue.length atInsertPosRef.current = null - const needsSpaceBefore = insertAt > 0 && !/\s/.test(value.charAt(insertAt - 1)) + const needsSpaceBefore = insertAt > 0 && !/\s/.test(currentValue.charAt(insertAt - 1)) const insertText = `${needsSpaceBefore ? ' ' : ''}@${resource.title} ` - const before = value.slice(0, insertAt) - const after = value.slice(insertAt) + const before = currentValue.slice(0, insertAt) + const after = currentValue.slice(insertAt) const newPos = before.length + insertText.length pendingCursorRef.current = newPos setValue(`${before}${insertText}${after}`) @@ -314,47 +208,35 @@ export function UserInput({ const context = mapResourceToContext(resource) handleContextAdd(context) - setPlusMenuOpen(false) }, - [textareaRef, value, handleContextAdd] + [textareaRef, handleContextAdd] ) - const handlePlusMenuSearchKeyDown = useCallback( - (e: React.KeyboardEvent) => { - const items = filteredPlusMenuItems - if (!items) return - if (e.key === 'ArrowDown') { - e.preventDefault() - setPlusMenuActiveIndex((prev) => Math.min(prev + 1, items.length - 1)) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - setPlusMenuActiveIndex((prev) => Math.max(prev - 1, 0)) - } else if (e.key === 'Enter') { - e.preventDefault() - if (items.length > 0 && items[plusMenuActiveIndex]) { - const { type, item } = items[plusMenuActiveIndex] - handleResourceSelect({ type, id: item.id, title: item.name }) - setPlusMenuOpen(false) - setPlusMenuSearch('') - setPlusMenuActiveIndex(0) - } - } - }, - [filteredPlusMenuItems, plusMenuActiveIndex, handleResourceSelect] - ) + const handlePlusMenuClose = useCallback(() => { + atInsertPosRef.current = null + }, []) - const handleContainerDragOver = useCallback( - (e: React.DragEvent) => { - if (e.dataTransfer.types.includes('application/x-sim-resource')) { - e.preventDefault() - e.stopPropagation() - e.dataTransfer.dropEffect = 'copy' - return - } - files.handleDragOver(e) - }, - [files] - ) + const handleFileSelectStable = useCallback(() => { + filesRef.current.handleFileSelect() + }, []) + + const handleFileClick = useCallback((file: AttachedFile) => { + filesRef.current.handleFileClick(file) + }, []) + + const handleRemoveFile = useCallback((id: string) => { + filesRef.current.removeFile(id) + }, []) + + const handleContainerDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes('application/x-sim-resource')) { + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'copy' + return + } + filesRef.current.handleDragOver(e) + }, []) const handleContainerDrop = useCallback( (e: React.DragEvent) => { @@ -370,11 +252,23 @@ export function UserInput({ } return } - files.handleDrop(e) + filesRef.current.handleDrop(e) }, - [handleResourceSelect, files] + [handleResourceSelect] ) + const handleDragEnter = useCallback((e: React.DragEvent) => { + filesRef.current.handleDragEnter(e) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + filesRef.current.handleDragLeave(e) + }, []) + + const handleFileChange = useCallback((e: React.ChangeEvent) => { + filesRef.current.handleFileChange(e) + }, []) + useEffect(() => { if (wasSendingRef.current && !isSending) { textareaRef.current?.focus() @@ -468,14 +362,18 @@ export function UserInput({ return } - prefixRef.current = value + prefixRef.current = valueRef.current if (startRecognition()) { setIsListening(true) } - }, [isListening, value, startRecognition]) + }, [isListening, startRecognition]) const handleSubmit = useCallback(() => { - const fileAttachmentsForApi: FileAttachmentForApi[] = files.attachedFiles + const currentFiles = filesRef.current + const currentContext = contextRef.current + const currentValue = valueRef.current + + const fileAttachmentsForApi: FileAttachmentForApi[] = currentFiles.attachedFiles .filter((f) => !f.uploading && f.key) .map((f) => ({ id: f.id, @@ -486,19 +384,19 @@ export function UserInput({ })) onSubmit( - value, + currentValue, fileAttachmentsForApi.length > 0 ? fileAttachmentsForApi : undefined, - contextManagement.selectedContexts.length > 0 ? contextManagement.selectedContexts : undefined + currentContext.selectedContexts.length > 0 ? currentContext.selectedContexts : undefined ) setValue('') restartRecognition('') - files.clearAttachedFiles() - contextManagement.clearContexts() + currentFiles.clearAttachedFiles() + currentContext.clearContexts() if (textareaRef.current) { textareaRef.current.style.height = 'auto' } - }, [onSubmit, files, value, contextManagement, textareaRef, restartRecognition]) + }, [onSubmit, restartRecognition, textareaRef]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -588,9 +486,7 @@ export function UserInput({ const adjusted = `${before}${after}` setValue(adjusted) atInsertPosRef.current = caret - 1 - setPlusMenuOpen(true) - setPlusMenuSearch('') - setPlusMenuActiveIndex(0) + plusMenuRef.current?.open() restartRecognition(adjusted) return } @@ -619,7 +515,6 @@ export function UserInput({ const maxHeight = isInitialView ? window.innerHeight * 0.3 : MAX_CHAT_TEXTAREA_HEIGHT autoResizeTextarea(e, maxHeight) - // Sync overlay scroll if (overlayRef.current) { overlayRef.current.scrollTop = (e.target as HTMLTextAreaElement).scrollTop } @@ -627,7 +522,13 @@ export function UserInput({ [isInitialView] ) - const renderOverlayContent = useCallback(() => { + const handleScroll = useCallback((e: React.UIEvent) => { + if (overlayRef.current) { + overlayRef.current.scrollTop = e.currentTarget.scrollTop + } + }, []) + + const overlayContent = useMemo(() => { const contexts = contextManagement.selectedContexts if (!value) { @@ -732,77 +633,26 @@ export function UserInput({ 'relative z-10 mx-auto w-full max-w-[42rem] cursor-text rounded-[20px] border border-[var(--border-1)] bg-[var(--white)] px-[10px] py-[8px] dark:bg-[var(--surface-4)]', isInitialView && 'shadow-sm' )} - onDragEnter={files.handleDragEnter} - onDragLeave={files.handleDragLeave} + onDragEnter={handleDragEnter} + onDragLeave={handleDragLeave} onDragOver={handleContainerDragOver} onDrop={handleContainerDrop} > - {/* Attached files */} - {files.attachedFiles.length > 0 && ( -
- {files.attachedFiles.map((file) => { - const isImage = file.type.startsWith('image/') - return ( - - -
files.handleFileClick(file)} - > - {isImage && file.previewUrl ? ( - {file.name} - ) : ( -
- {(() => { - const Icon = getDocumentIcon(file.type, file.name) - return - })()} - - {file.name.split('.').pop()} - -
- )} - {file.uploading && ( -
- -
- )} - {!file.uploading && ( - - )} -
-
- -

{file.name}

-
-
- ) - })} -
- )} + + + - {/* Textarea with overlay for highlighting */}
- {/* Highlight overlay */}