diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts index 2293dbd211..bc1c46b6d2 100644 --- a/apps/sim/app/api/mothership/chat/route.ts +++ b/apps/sim/app/api/mothership/chat/route.ts @@ -55,6 +55,8 @@ const MothershipMessageSchema = z.object({ 'knowledge', 'templates', 'docs', + 'table', + 'file', ]), label: z.string(), chatId: z.string().optional(), @@ -64,6 +66,8 @@ const MothershipMessageSchema = z.object({ blockIds: z.array(z.string()).optional(), templateId: z.string().optional(), executionId: z.string().optional(), + tableId: z.string().optional(), + fileId: z.string().optional(), }) ) .optional(), @@ -162,6 +166,17 @@ export async function POST(req: NextRequest) { size: f.size, })), }), + ...(contexts && + contexts.length > 0 && { + contexts: contexts.map((c) => ({ + kind: c.kind, + label: c.label, + ...(c.workflowId && { workflowId: c.workflowId }), + ...(c.knowledgeId && { knowledgeId: c.knowledgeId }), + ...(c.tableId && { tableId: c.tableId }), + ...(c.fileId && { fileId: c.fileId }), + })), + }), } const [updated] = await db diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/index.ts index f0de52bbdb..5822cb8cbe 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/index.ts @@ -2,3 +2,4 @@ export { MessageContent } from './message-content' export { MothershipView } from './mothership-view' export { TemplatePrompts } from './template-prompts' export { UserInput } from './user-input' +export { UserMessageContent } from './user-message-content' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/context-pills.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/context-pills.tsx new file mode 100644 index 0000000000..854c18bef4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/context-pills.tsx @@ -0,0 +1,99 @@ +'use client' + +import { X } from 'lucide-react' +import { Badge } from '@/components/emcn' +import { Database, File as FileIcon, Table as TableIcon } from '@/components/emcn/icons' +import { WorkflowIcon } from '@/components/icons' +import { cn } from '@/lib/core/utils/cn' +import type { ChatContext } from '@/stores/panel' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +interface ContextPillsProps { + contexts: ChatContext[] + onRemoveContext: (context: ChatContext) => void +} + +function WorkflowPillIcon({ workflowId, className }: { workflowId: string; className?: string }) { + const color = useWorkflowRegistry((state) => state.workflows[workflowId]?.color ?? '#888') + return ( +
+ ) +} + +function getContextIcon(ctx: ChatContext) { + switch (ctx.kind) { + case 'workflow': + case 'current_workflow': + return ( + + ) + case 'workflow_block': + return ( + + ) + case 'knowledge': + return + case 'templates': + return + case 'past_chat': + return null + case 'logs': + return + case 'blocks': + return + case 'table': + return + case 'file': + return + case 'docs': + return + default: + return null + } +} + +export function ContextPills({ contexts, onRemoveContext }: ContextPillsProps) { + const visibleContexts = contexts.filter((c) => c.kind !== 'current_workflow') + + if (visibleContexts.length === 0) { + return null + } + + return ( + <> + {visibleContexts.map((ctx, idx) => ( + + {getContextIcon(ctx)} + {ctx.label} + + + ))} + + ) +} 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..6ba5eb0cd3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts @@ -0,0 +1 @@ +export { ContextPills } from './context-pills' 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 6db2dfc805..1a0066eec0 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 @@ -31,9 +31,26 @@ type WindowWithSpeech = Window & { webkitSpeechRecognition?: SpeechRecognitionStatic } -import { useCallback, useEffect, useRef, useState } from 'react' -import { ArrowUp, Loader2, Mic, Paperclip, X } from 'lucide-react' -import { Button, Tooltip } from '@/components/emcn' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ArrowUp, AtSign, ChevronRight, Folder, Loader2, Mic, Paperclip, Plus, X } from 'lucide-react' +import { useParams } from 'next/navigation' +import { createPortal } from 'react-dom' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, + Popover, + PopoverAnchor, + PopoverContent, + Tooltip, +} from '@/components/emcn' +import { Search } from '@/components/emcn/icons' import { AudioIcon, CsvIcon, @@ -46,21 +63,51 @@ import { VideoIcon, XlsxIcon, } 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 { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' +import { ContextPills } from './components' +import { + useCaretViewport, + useContextManagement, + useFileAttachments, + useMentionMenu, + useMentionTokens, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks' +import { + computeMentionHighlightRanges, + extractContextTokens, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' +import type { ChatContext } from '@/stores/panel' +import { + useAvailableResources, + type AvailableItem, +} 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 { MothershipResource, MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' +import { useFolders } from '@/hooks/queries/folders' +import { useFolderStore } from '@/stores/folders/store' +import type { FolderTreeNode } from '@/stores/folders/types' 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-words border-0 bg-transparent', 'px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em]', - 'text-[var(--text-primary)] outline-none', + '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 break-words 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)]' @@ -86,6 +133,441 @@ function autoResizeTextarea(e: React.FormEvent, maxHeight: 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 } + } +} + +interface ResourceMentionMenuProps { + workspaceId: string + textareaRef: React.RefObject + message: string + caretPos: number + availableResources: ReturnType + onSelect: (resource: MothershipResource, fromMentionMenu: boolean) => void + onClose: () => void + query: string +} + +function ResourceMentionMenu({ + workspaceId, + textareaRef, + message, + caretPos, + availableResources, + onSelect, + onClose, + query, +}: ResourceMentionMenuProps) { + const { caretViewport, side } = useCaretViewport({ textareaRef, message, caretPos }) + const menuRef = useRef(null) + const [activeIndex, setActiveIndex] = useState(0) + + // Flatten all items for keyboard navigation, filtered by query + const flatItems = useMemo(() => { + const searchQuery = query.trim().toLowerCase() + if (searchQuery) { + return availableResources.flatMap(({ type, items }) => + items + .filter((item) => item.name.toLowerCase().includes(searchQuery)) + .map((item) => ({ type, item })) + ) + } + // When no query, show all items flat + return availableResources.flatMap(({ type, items }) => + items.map((item) => ({ type, item })) + ) + }, [availableResources, query]) + + // Reset active index when query changes + useEffect(() => { + setActiveIndex(0) + }, [query]) + + const handleSelect = useCallback( + (resource: MothershipResource) => { + onSelect(resource, true) + onClose() + }, + [onSelect, onClose] + ) + + // Keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setActiveIndex((prev) => Math.min(prev + 1, flatItems.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIndex((prev) => Math.max(prev - 1, 0)) + } else if (e.key === 'Tab' || e.key === 'Enter') { + e.preventDefault() + if (flatItems.length > 0 && flatItems[activeIndex]) { + const { type, item } = flatItems[activeIndex] + handleSelect({ type, id: item.id, title: item.name }) + } + } else if (e.key === 'Escape') { + e.preventDefault() + onClose() + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [flatItems, activeIndex, handleSelect, onClose]) + + if (!caretViewport) return null + + return ( + !open && onClose()}> + +
+ + e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > +
+ {flatItems.length > 0 ? ( + flatItems.map(({ type, item }, index) => { + const config = getResourceConfig(type) + return ( +
handleSelect({ type, id: item.id, title: item.name })} + className={cn( + 'flex cursor-pointer items-center gap-[8px] px-[8px] py-[6px] text-[13px]', + index === activeIndex ? 'bg-[var(--surface-active)]' : 'hover:bg-[var(--surface-active)]' + )} + > + {config.renderDropdownItem({ item })} + + {config.label} + +
+ ) + }) + ) : ( +
+ No results +
+ )} +
+
+ + ) +} + +interface ResourceTypeFolderProps { + type: MothershipResourceType + items: AvailableItem[] + config: ReturnType + workspaceId: string + onSelect: (resource: MothershipResource) => void +} + +function ResourceTypeFolder({ type, items, config, workspaceId, onSelect }: ResourceTypeFolderProps) { + const [expanded, setExpanded] = useState(false) + const Icon = config.icon + + if (items.length === 0) { + return ( +
+ + {config.label} + None +
+ ) + } + + return ( + <> +
{ + e.preventDefault() + e.stopPropagation() + setExpanded(!expanded) + }} + className='flex cursor-pointer items-center gap-[8px] px-[8px] py-[6px] text-[13px] hover:bg-[var(--surface-active)]' + > + + + {config.label} + {items.length} +
+ {expanded && ( +
+ {type === 'workflow' ? ( + onSelect({ type, id: item.id, title: item.name })} + /> + ) : ( + items.map((item) => ( +
onSelect({ type, id: item.id, title: item.name })} + className='flex cursor-pointer items-center gap-[8px] px-[8px] py-[6px] text-[13px] hover:bg-[var(--surface-active)]' + > + {config.renderDropdownItem({ item })} +
+ )) + )} +
+ )} + + ) +} + +function WorkflowFolderContent({ + workspaceId, + items, + config, + onSelect, +}: { + workspaceId: string + items: AvailableItem[] + config: ReturnType + onSelect: (item: AvailableItem) => void +}) { + useFolders(workspaceId) + const folders = useFolderStore((state) => state.folders) + const getFolderTree = useFolderStore((state) => state.getFolderTree) + const folderTree = useMemo( + () => getFolderTree(workspaceId), + // eslint-disable-next-line react-hooks/exhaustive-deps + [folders, getFolderTree, workspaceId] + ) + const [expanded, setExpanded] = useState>(new Set()) + + const toggleFolder = useCallback((id: string) => { + setExpanded((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }, []) + + const workflowsByFolder = useMemo(() => { + const grouped: Record = {} + for (const item of items) { + const fId = (item.folderId as string | null) ?? 'root' + if (!grouped[fId]) grouped[fId] = [] + grouped[fId].push(item) + } + return grouped + }, [items]) + + const rootWorkflows = workflowsByFolder.root ?? [] + + const folderTreeHasItems = useCallback( + (folder: FolderTreeNode): boolean => { + if (workflowsByFolder[folder.id]?.length) return true + return folder.children.some(folderTreeHasItems) + }, + [workflowsByFolder] + ) + + const visibleFolders = useMemo( + () => folderTree.filter(folderTreeHasItems), + [folderTree, folderTreeHasItems] + ) + + const renderFolder = (folder: FolderTreeNode, level: number) => { + const folderWorkflows = workflowsByFolder[folder.id] ?? [] + const isExpanded = expanded.has(folder.id) + const indent = level * 12 + + return ( +
+
{ + e.preventDefault() + e.stopPropagation() + toggleFolder(folder.id) + }} + className='flex cursor-pointer items-center gap-[6px] px-[8px] py-[6px] text-[13px] hover:bg-[var(--surface-active)]' + style={{ paddingLeft: `${8 + indent}px` }} + > + + + {folder.name} +
+ {isExpanded && ( + <> + {folder.children.map((child) => renderFolder(child, level + 1))} + {folderWorkflows.map((item) => ( +
onSelect(item)} + className='flex cursor-pointer items-center gap-[8px] px-[8px] py-[6px] text-[13px] hover:bg-[var(--surface-active)]' + style={{ paddingLeft: `${8 + (level + 1) * 12}px` }} + > + {config.renderDropdownItem({ item })} +
+ ))} + + )} +
+ ) + } + + return ( + <> + {visibleFolders.map((folder) => renderFolder(folder, 0))} + {rootWorkflows.map((item) => ( +
onSelect(item)} + className='flex cursor-pointer items-center gap-[8px] px-[8px] py-[6px] text-[13px] hover:bg-[var(--surface-active)]' + > + {config.renderDropdownItem({ item })} +
+ ))} + + ) +} + +interface ResourcesSubmenuContentProps { + workspaceId: string + availableResources: ReturnType + onSelect: (resource: MothershipResource) => void +} + +function ResourcesSubmenuContent({ + workspaceId, + availableResources, + onSelect, +}: ResourcesSubmenuContentProps) { + const [search, setSearch] = useState('') + const inputRef = useRef(null) + + useEffect(() => { + inputRef.current?.focus() + }, []) + + const query = search.trim().toLowerCase() + const filtered = useMemo(() => { + if (!query) return null + return availableResources.flatMap(({ type, items }) => + items + .filter((item) => item.name.toLowerCase().includes(query)) + .map((item) => ({ type, item })) + ) + }, [availableResources, query]) + + const handleSelect = useCallback( + (resource: MothershipResource) => { + onSelect(resource) + }, + [onSelect] + ) + + return ( + <> +
e.stopPropagation()} + > + + setSearch(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + placeholder='Search resources…' + className='h-[20px] w-full bg-transparent text-[13px] text-[var(--text-primary)] outline-none placeholder:text-[var(--text-tertiary)]' + /> +
+ +
+ {filtered ? ( + filtered.length > 0 ? ( + filtered.map(({ type, item }) => { + const config = getResourceConfig(type) + return ( +
handleSelect({ type, id: item.id, title: item.name })} + className='flex cursor-pointer items-center gap-[8px] px-[8px] py-[6px] text-[13px] hover:bg-[var(--surface-active)]' + > + {config.renderDropdownItem({ item })} + + {config.label} + +
+ ) + }) + ) : ( +
+ No results +
+ ) + ) : ( + availableResources.map(({ type, items }) => { + const config = getResourceConfig(type) + return ( + + ) + }) + )} +
+ + ) +} + export interface FileAttachmentForApi { id: string key: string @@ -96,11 +578,12 @@ export interface FileAttachmentForApi { interface UserInputProps { defaultValue?: string - onSubmit: (text: string, fileAttachments?: FileAttachmentForApi[]) => void + onSubmit: (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => void isSending: boolean onStopGeneration: () => void isInitialView?: boolean userId?: string + onContextAdd?: (context: ChatContext) => void } export function UserInput({ @@ -110,8 +593,13 @@ export function UserInput({ onStopGeneration, isInitialView = true, userId, + onContextAdd, }: UserInputProps) { + const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: session } = useSession() const [value, setValue] = useState(defaultValue) + const [plusMenuOpen, setPlusMenuOpen] = useState(false) + const overlayRef = useRef(null) useEffect(() => { if (defaultValue) setValue(defaultValue) @@ -120,8 +608,47 @@ export function UserInput({ const animatedPlaceholder = useAnimatedPlaceholder(isInitialView) const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim' - const files = useFileAttachments({ userId, disabled: false, isLoading: isSending }) + const files = useFileAttachments({ userId: userId || session?.user?.id, disabled: false, isLoading: isSending }) const hasFiles = files.attachedFiles.some((f) => !f.uploading && f.key) + + const contextManagement = useContextManagement({ message: value }) + + const handleContextAdd = useCallback( + (context: ChatContext) => { + contextManagement.addContext(context) + onContextAdd?.(context) + }, + [contextManagement, onContextAdd] + ) + + const existingResourceKeys = useMemo(() => { + const keys = new Set() + for (const ctx of contextManagement.selectedContexts) { + if (ctx.kind === 'workflow' && ctx.workflowId) keys.add(`workflow:${ctx.workflowId}`) + if (ctx.kind === 'knowledge' && ctx.knowledgeId) keys.add(`knowledgebase:${ctx.knowledgeId}`) + if (ctx.kind === 'table' && ctx.tableId) keys.add(`table:${ctx.tableId}`) + if (ctx.kind === 'file' && ctx.fileId) keys.add(`file:${ctx.fileId}`) + } + return keys + }, [contextManagement.selectedContexts]) + + const availableResources = useAvailableResources(workspaceId, existingResourceKeys) + + const mentionMenu = useMentionMenu({ + message: value, + selectedContexts: contextManagement.selectedContexts, + onContextSelect: handleContextAdd, + onMessageChange: setValue, + }) + + const mentionTokensWithContext = useMentionTokens({ + message: value, + selectedContexts: contextManagement.selectedContexts, + mentionMenu, + setMessage: setValue, + setSelectedContexts: contextManagement.setSelectedContexts, + }) + const canSubmit = (value.trim().length > 0 || hasFiles) && !isSending const [isListening, setIsListening] = useState(false) @@ -134,26 +661,58 @@ export function UserInput({ } }, []) - const textareaRef = useRef(null) + const textareaRef = mentionMenu.textareaRef const wasSendingRef = useRef(false) + const handleResourceSelect = useCallback( + (resource: MothershipResource, fromMentionMenu = false) => { + if (fromMentionMenu) { + // Use replaceActiveMentionWith to replace @query with @label + mentionMenu.replaceActiveMentionWith(resource.title) + } else { + // Insert fresh @mention (from + menu) + const textarea = textareaRef.current + if (textarea) { + textarea.focus() + const start = textarea.selectionStart ?? value.length + const needsSpaceBefore = start > 0 && !/\s/.test(value.charAt(start - 1)) + const insertText = `${needsSpaceBefore ? ' ' : ''}@${resource.title} ` + const before = value.slice(0, start) + const after = value.slice(start) + setValue(`${before}${insertText}${after}`) + + setTimeout(() => { + const newPos = before.length + insertText.length + textarea.setSelectionRange(newPos, newPos) + textarea.focus() + }, 0) + } + } + + const context = mapResourceToContext(resource) + handleContextAdd(context) + setPlusMenuOpen(false) + }, + [textareaRef, value, handleContextAdd, mentionMenu] + ) + useEffect(() => { if (wasSendingRef.current && !isSending) { textareaRef.current?.focus() } wasSendingRef.current = isSending - }, [isSending]) + }, [isSending, textareaRef]) useEffect(() => { if (isInitialView) { textareaRef.current?.focus() } - }, [isInitialView]) + }, [isInitialView, textareaRef]) const handleContainerClick = useCallback((e: React.MouseEvent) => { if ((e.target as HTMLElement).closest('button')) return textareaRef.current?.focus() - }, []) + }, [textareaRef]) const handleSubmit = useCallback(() => { const fileAttachmentsForApi: FileAttachmentForApi[] = files.attachedFiles @@ -166,33 +725,148 @@ export function UserInput({ size: f.size, })) - onSubmit(value, fileAttachmentsForApi.length > 0 ? fileAttachmentsForApi : undefined) + onSubmit( + value, + fileAttachmentsForApi.length > 0 ? fileAttachmentsForApi : undefined, + contextManagement.selectedContexts.length > 0 ? contextManagement.selectedContexts : undefined + ) setValue('') files.clearAttachedFiles() + contextManagement.clearContexts() + mentionMenu.setShowMentionMenu(false) if (textareaRef.current) { textareaRef.current.style.height = 'auto' } - }, [onSubmit, files, value]) + }, [onSubmit, files, value, contextManagement, mentionMenu, textareaRef]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { + if (e.key === 'Escape' && mentionMenu.showMentionMenu) { + e.preventDefault() + mentionMenu.closeMentionMenu() + return + } + + if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault() - if (!isSending) handleSubmit() + if (!mentionMenu.showMentionMenu && !isSending) { + handleSubmit() + } + return + } + + if (!mentionMenu.showMentionMenu) { + const textarea = textareaRef.current + const selStart = textarea?.selectionStart ?? 0 + const selEnd = textarea?.selectionEnd ?? selStart + const selectionLength = Math.abs(selEnd - selStart) + + if (e.key === 'Backspace' || e.key === 'Delete') { + if (selectionLength > 0) { + mentionTokensWithContext.removeContextsInSelection(selStart, selEnd) + } else { + const ranges = mentionTokensWithContext.computeMentionRanges() + const target = + e.key === 'Backspace' + ? ranges.find((r) => selStart > r.start && selStart <= r.end) + : ranges.find((r) => selStart >= r.start && selStart < r.end) + + if (target) { + e.preventDefault() + mentionTokensWithContext.deleteRange(target) + return + } + } + } + + if (selectionLength === 0 && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { + if (textarea) { + if (e.key === 'ArrowLeft') { + const nextPos = Math.max(0, selStart - 1) + const r = mentionTokensWithContext.findRangeContaining(nextPos) + if (r) { + e.preventDefault() + const target = r.start + setTimeout(() => textarea.setSelectionRange(target, target), 0) + return + } + } else if (e.key === 'ArrowRight') { + const nextPos = Math.min(value.length, selStart + 1) + const r = mentionTokensWithContext.findRangeContaining(nextPos) + if (r) { + e.preventDefault() + const target = r.end + setTimeout(() => textarea.setSelectionRange(target, target), 0) + return + } + } + } + } + + if (e.key.length === 1 || e.key === 'Space') { + const blocked = + selectionLength === 0 && !!mentionTokensWithContext.findRangeContaining(selStart) + if (blocked) { + e.preventDefault() + const r = mentionTokensWithContext.findRangeContaining(selStart) + if (r && textarea) { + setTimeout(() => { + textarea.setSelectionRange(r.end, r.end) + }, 0) + } + return + } + } + } + }, + [handleSubmit, isSending, mentionMenu, mentionTokensWithContext, value, textareaRef] + ) + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value + setValue(newValue) + + const caret = e.target.selectionStart ?? newValue.length + const activeMention = mentionMenu.getActiveMentionQueryAtPosition(caret, newValue) + + if (activeMention) { + mentionMenu.setShowMentionMenu(true) + } else { + mentionMenu.setShowMentionMenu(false) } }, - [handleSubmit, isSending] + [mentionMenu] ) + const handleSelectAdjust = useCallback(() => { + const textarea = textareaRef.current + if (!textarea) return + const pos = textarea.selectionStart ?? 0 + const r = mentionTokensWithContext.findRangeContaining(pos) + if (r) { + const snapPos = pos - r.start < r.end - pos ? r.start : r.end + setTimeout(() => { + textarea.setSelectionRange(snapPos, snapPos) + }, 0) + } + }, [textareaRef, mentionTokensWithContext]) + const handleInput = useCallback( (e: React.FormEvent) => { 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 + } }, [isInitialView] ) + const toggleListening = useCallback(() => { if (isListening) { recognitionRef.current?.stop() @@ -243,6 +917,57 @@ export function UserInput({ setIsListening(true) }, [isListening, value]) + const renderOverlayContent = useCallback(() => { + const contexts = contextManagement.selectedContexts + + if (!value) { + return {'\u00A0'} + } + + if (contexts.length === 0) { + const displayText = value.endsWith('\n') ? `${value}\u200B` : value + return {displayText} + } + + const tokens = extractContextTokens(contexts) + const ranges = computeMentionHighlightRanges(value, tokens) + + if (ranges.length === 0) { + const displayText = value.endsWith('\n') ? `${value}\u200B` : value + return {displayText} + } + + const elements: React.ReactNode[] = [] + let lastIndex = 0 + + for (let i = 0; i < ranges.length; i++) { + const range = ranges[i] + + if (range.start > lastIndex) { + const before = value.slice(lastIndex, range.start) + elements.push({before}) + } + + elements.push( + + {range.token} + + ) + lastIndex = range.end + } + + const tail = value.slice(lastIndex) + if (tail) { + const displayTail = tail.endsWith('\n') ? `${tail}\u200B` : tail + elements.push({displayTail}) + } + + return elements.length > 0 ? elements : {'\u00A0'} + }, [value, contextManagement.selectedContexts]) + return (
+ {/* Context pills row */} + {contextManagement.selectedContexts.length > 0 && ( +
+ +
+ )} + {/* Attached files */} {files.attachedFiles.length > 0 && (
@@ -312,28 +1047,103 @@ export function UserInput({
)} -