From 77094be57fbe2908f6befffb883b5e343f568b70 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Mar 2026 23:57:24 -0700 Subject: [PATCH 1/6] wip --- .../[workspaceId]/home/components/index.ts | 1 + .../user-input/components/context-pills.tsx | 95 ++++ .../components/user-input/components/index.ts | 1 + .../home/components/user-input/user-input.tsx | 477 ++++++++++++++++-- .../home/components/user-message-content.tsx | 115 +++++ .../app/workspace/[workspaceId]/home/home.tsx | 50 +- .../[workspaceId]/home/hooks/use-chat.ts | 21 +- .../app/workspace/[workspaceId]/home/types.ts | 8 + 8 files changed, 723 insertions(+), 45 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/context-pills.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-message-content.tsx 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..dc2d3812a9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/context-pills.tsx @@ -0,0 +1,95 @@ +'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 '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..c713753364 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 @@ -32,8 +32,17 @@ type WindowWithSpeech = Window & { } import { useCallback, useEffect, useRef, useState } from 'react' -import { ArrowUp, Loader2, Mic, Paperclip, X } from 'lucide-react' -import { Button, Tooltip } from '@/components/emcn' +import { ArrowUp, AtSign, Loader2, Mic, Paperclip, Plus, X } from 'lucide-react' +import { useParams } from 'next/navigation' +import { createPortal } from 'react-dom' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Tooltip, +} from '@/components/emcn' import { AudioIcon, CsvIcon, @@ -46,21 +55,48 @@ 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 { + type MentionFolderNav, + MentionMenu, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components' +import { ContextPills } from './components' +import { + useContextManagement, + useFileAttachments, + useMentionData, + useMentionInsertHandlers, + useMentionKeyboard, + 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 { 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)]' @@ -96,11 +132,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 +147,14 @@ export function UserInput({ onStopGeneration, isInitialView = true, userId, + onContextAdd, }: UserInputProps) { + const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: session } = useSession() const [value, setValue] = useState(defaultValue) + const [mentionFolderNav, setMentionFolderNav] = useState(null) + const [plusMenuOpen, setPlusMenuOpen] = useState(false) + const overlayRef = useRef(null) useEffect(() => { if (defaultValue) setValue(defaultValue) @@ -120,8 +163,54 @@ 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 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 mentionData = useMentionData({ + workflowId: null, + workspaceId, + }) + + const insertHandlers = useMentionInsertHandlers({ + mentionMenu, + workflowId: null, + selectedContexts: contextManagement.selectedContexts, + onContextAdd: handleContextAdd, + mentionFolderNav, + }) + + const mentionKeyboard = useMentionKeyboard({ + mentionMenu, + mentionData, + insertHandlers, + mentionFolderNav, + }) + const canSubmit = (value.trim().length > 0 || hasFiles) && !isSending const [isListening, setIsListening] = useState(false) @@ -134,7 +223,7 @@ export function UserInput({ } }, []) - const textareaRef = useRef(null) + const textareaRef = mentionMenu.textareaRef const wasSendingRef = useRef(false) useEffect(() => { @@ -142,18 +231,50 @@ export function UserInput({ textareaRef.current?.focus() } wasSendingRef.current = isSending - }, [isSending]) + }, [isSending, textareaRef]) useEffect(() => { if (isInitialView) { textareaRef.current?.focus() } - }, [isInitialView]) + }, [isInitialView, textareaRef]) + + // Load mention data when menu is shown + useEffect(() => { + if (!mentionMenu.showMentionMenu || mentionFolderNav?.isInFolder) { + return + } + + const q = mentionMenu + .getActiveMentionQueryAtPosition(mentionMenu.getCaretPos()) + ?.query.trim() + .toLowerCase() + + if (q && q.length > 0) { + void mentionData.ensurePastChatsLoaded() + void mentionData.ensureKnowledgeLoaded() + void mentionData.ensureBlocksLoaded() + void mentionData.ensureTemplatesLoaded() + void mentionData.ensureLogsLoaded() + + mentionMenu.setSubmenuActiveIndex(0) + requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(0)) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mentionMenu.showMentionMenu, mentionFolderNav?.isInFolder, value]) + + useEffect(() => { + if (mentionFolderNav?.isInFolder) { + mentionMenu.setSubmenuActiveIndex(0) + requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(0)) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mentionFolderNav?.isInFolder]) 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 +287,203 @@ 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() + if (mentionFolderNav?.isInFolder) { + mentionFolderNav.closeFolder() + mentionMenu.setSubmenuQueryStart(null) + } else { + mentionMenu.closeMentionMenu() + } + return + } + + if (mentionKeyboard.handleArrowNavigation(e)) return + if (mentionKeyboard.handleArrowRight(e)) return + if (mentionKeyboard.handleArrowLeft(e)) return + + if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault() - if (!isSending) handleSubmit() + if (!mentionMenu.showMentionMenu) { + if (!isSending) handleSubmit() + } else { + mentionKeyboard.handleEnterSelection(e) + } + 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] + [handleSubmit, isSending, mentionMenu, mentionKeyboard, mentionTokensWithContext, value, textareaRef, mentionFolderNav] ) + 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) + mentionMenu.setInAggregated(false) + if (mentionFolderNav?.isInFolder) { + mentionMenu.setSubmenuActiveIndex(0) + } else { + mentionMenu.setMentionActiveIndex(0) + mentionMenu.setSubmenuActiveIndex(0) + } + } else { + mentionMenu.setShowMentionMenu(false) + mentionMenu.setOpenSubmenuFor(null) + mentionMenu.setSubmenuQueryStart(null) + } + }, + [mentionMenu, mentionFolderNav] + ) + + 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 insertTriggerAndOpenMenu = useCallback(() => { + if (isSending) return + const textarea = textareaRef.current + if (!textarea) return + + textarea.focus() + const start = textarea.selectionStart ?? value.length + const end = textarea.selectionEnd ?? value.length + const needsSpaceBefore = start > 0 && !/\s/.test(value.charAt(start - 1)) + + const insertText = needsSpaceBefore ? ' @' : '@' + const before = value.slice(0, start) + const after = value.slice(end) + setValue(`${before}${insertText}${after}`) + + setTimeout(() => { + const newPos = before.length + insertText.length + textarea.setSelectionRange(newPos, newPos) + textarea.focus() + }, 0) + + mentionMenu.setShowMentionMenu(true) + mentionMenu.setOpenSubmenuFor(null) + mentionMenu.setMentionActiveIndex(0) + mentionMenu.setSubmenuActiveIndex(0) + }, [isSending, textareaRef, value, mentionMenu]) + + const handlePlusMenuSelect = useCallback((action: 'attachments' | 'resources') => { + setPlusMenuOpen(false) + if (action === 'attachments') { + files.handleFileSelect() + } else { + insertTriggerAndOpenMenu() + } + }, [files, insertTriggerAndOpenMenu]) + const toggleListening = useCallback(() => { if (isListening) { recognitionRef.current?.stop() @@ -243,6 +534,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 +664,81 @@ export function UserInput({
)} -