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({
)}
-
)}
)
@@ -384,6 +433,7 @@ export function Home({ chatId }: HomeProps = {}) {
onStopGeneration={stopGeneration}
isInitialView={false}
userId={session?.user?.id}
+ onContextAdd={handleContextAdd}
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
index 58d501a118..3329290d25 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
@@ -20,6 +20,7 @@ import {
taskKeys,
useChatHistory,
} from '@/hooks/queries/tasks'
+import type { ChatContext } from '@/stores/panel'
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
import { useExecutionStream } from '@/hooks/use-execution-stream'
import { useExecutionStore } from '@/stores/execution/store'
@@ -45,7 +46,7 @@ export interface UseChatReturn {
isSending: boolean
error: string | null
resolvedChatId: string | undefined
- sendMessage: (message: string, fileAttachments?: FileAttachmentForApi[]) => Promise
+ sendMessage: (message: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => Promise
stopGeneration: () => Promise
resources: MothershipResource[]
activeResourceId: string | null
@@ -145,6 +146,17 @@ function mapStoredMessage(msg: TaskStoredMessage): ChatMessage {
mapped.attachments = msg.fileAttachments.map(toDisplayAttachment)
}
+ if (Array.isArray(msg.contexts) && msg.contexts.length > 0) {
+ mapped.contexts = msg.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 }),
+ }))
+ }
+
return mapped
}
@@ -257,6 +269,18 @@ export function useChat(
return [...prev, resource]
})
setActiveResourceId(resource.id)
+
+ // Persist to database if we have a chat ID
+ const currentChatId = chatIdRef.current
+ if (currentChatId) {
+ fetch('/api/copilot/chat/resources', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ chatId: currentChatId, resource }),
+ }).catch((err) => {
+ logger.warn('Failed to persist resource', err)
+ })
+ }
}, [])
const removeResource = useCallback((resourceType: MothershipResourceType, resourceId: string) => {
@@ -695,7 +719,7 @@ export function useChat(
}, [chatHistory?.activeStreamId, processSSEStream, finalize])
const sendMessage = useCallback(
- async (message: string, fileAttachments?: FileAttachmentForApi[]) => {
+ async (message: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
if (!message.trim() || !workspaceId) return
if (sendingRef.current) {
@@ -746,9 +770,24 @@ export function useChat(
const userAttachments = storedAttachments?.map(toDisplayAttachment)
+ const messageContexts = contexts?.map((c) => ({
+ kind: c.kind,
+ label: c.label,
+ ...('workflowId' in c && c.workflowId ? { workflowId: c.workflowId } : {}),
+ ...('knowledgeId' in c && c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}),
+ ...('tableId' in c && c.tableId ? { tableId: c.tableId } : {}),
+ ...('fileId' in c && c.fileId ? { fileId: c.fileId } : {}),
+ }))
+
setMessages((prev) => [
...prev,
- { id: userMessageId, role: 'user', content: message, attachments: userAttachments },
+ {
+ id: userMessageId,
+ role: 'user',
+ content: message,
+ attachments: userAttachments,
+ ...(messageContexts && messageContexts.length > 0 ? { contexts: messageContexts } : {}),
+ },
{ id: assistantId, role: 'assistant', content: '', contentBlocks: [] },
])
@@ -776,6 +815,7 @@ export function useChat(
...(chatIdRef.current ? { chatId: chatIdRef.current } : {}),
...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}),
...(resourceAttachments ? { resourceAttachments } : {}),
+ ...(contexts && contexts.length > 0 ? { contexts } : {}),
userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}),
signal: abortController.signal,
diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts
index dfa482894c..0be840c267 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/types.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts
@@ -141,12 +141,22 @@ export interface ChatMessageAttachment {
previewUrl?: string
}
+export interface ChatMessageContext {
+ kind: string
+ label: string
+ workflowId?: string
+ knowledgeId?: string
+ tableId?: string
+ fileId?: string
+}
+
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
contentBlocks?: ContentBlock[]
attachments?: ChatMessageAttachment[]
+ contexts?: ChatMessageContext[]
}
export const SUBAGENT_LABELS: Record = {
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts
index 4859409219..3e9a390f5a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts
@@ -283,7 +283,7 @@ export function useMentionMenu({
// Add leading space only if not at start and previous char isn't whitespace
const needsLeadingSpace = before.length > 0 && !before.endsWith(' ')
// Always add trailing space for easy continued typing
- const insertion = `${needsLeadingSpace ? ' ' : ''}@${label} `
+ const insertion = `${needsLeadingSpace ? ' ' : ''}@${label} `
const next = `${before}${insertion}${after}`
onMessageChange(next)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts
index a367409344..5608095c24 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts
@@ -148,6 +148,8 @@ type CurrentWorkflowContext = Extract
type BlocksContext = Extract
type WorkflowBlockContext = Extract
type KnowledgeContext = Extract
+type TableContext = Extract
+type FileContext = Extract
type TemplatesContext = Extract
type LogsContext = Extract
type SlashCommandContext = Extract
@@ -184,6 +186,14 @@ export function areContextsEqual(c: ChatContext, context: ChatContext): boolean
const ctx = context as KnowledgeContext
return c.knowledgeId === ctx.knowledgeId
}
+ case 'table': {
+ const ctx = context as TableContext
+ return c.tableId === ctx.tableId
+ }
+ case 'file': {
+ const ctx = context as FileContext
+ return c.fileId === ctx.fileId
+ }
case 'templates': {
const ctx = context as TemplatesContext
return c.templateId === ctx.templateId
diff --git a/apps/sim/hooks/queries/tasks.ts b/apps/sim/hooks/queries/tasks.ts
index 7c93156950..2f006db1bd 100644
--- a/apps/sim/hooks/queries/tasks.ts
+++ b/apps/sim/hooks/queries/tasks.ts
@@ -35,6 +35,15 @@ export interface TaskStoredFileAttachment {
size: number
}
+export interface TaskStoredMessageContext {
+ kind: string
+ label: string
+ workflowId?: string
+ knowledgeId?: string
+ tableId?: string
+ fileId?: string
+}
+
export interface TaskStoredMessage {
id: string
role: 'user' | 'assistant'
@@ -42,6 +51,7 @@ export interface TaskStoredMessage {
toolCalls?: TaskStoredToolCall[]
contentBlocks?: TaskStoredContentBlock[]
fileAttachments?: TaskStoredFileAttachment[]
+ contexts?: TaskStoredMessageContext[]
}
export interface TaskStoredContentBlock {
diff --git a/apps/sim/lib/copilot/process-contents.ts b/apps/sim/lib/copilot/process-contents.ts
index f1f9da7266..1c8c21b401 100644
--- a/apps/sim/lib/copilot/process-contents.ts
+++ b/apps/sim/lib/copilot/process-contents.ts
@@ -21,6 +21,8 @@ export type AgentContextType =
| 'blocks'
| 'logs'
| 'knowledge'
+ | 'table'
+ | 'file'
| 'templates'
| 'workflow_block'
| 'docs'
@@ -120,6 +122,16 @@ export async function processContextsServer(
if (ctx.kind === 'workflow_block' && ctx.workflowId && ctx.blockId) {
return await processWorkflowBlockFromDb(ctx.workflowId, ctx.blockId, ctx.label)
}
+ if (ctx.kind === 'table' && ctx.tableId) {
+ const result = await resolveTableResource(ctx.tableId)
+ if (!result) return null
+ return { type: 'table', tag: ctx.label ? `@${ctx.label}` : '@', content: result.content }
+ }
+ if (ctx.kind === 'file' && ctx.fileId && workspaceId) {
+ const result = await resolveFileResource(ctx.fileId, workspaceId)
+ if (!result) return null
+ return { type: 'file', tag: ctx.label ? `@${ctx.label}` : '@', content: result.content }
+ }
if (ctx.kind === 'docs') {
try {
const { searchDocumentationServerTool } = await import(
diff --git a/apps/sim/stores/panel/copilot/types.ts b/apps/sim/stores/panel/copilot/types.ts
index 205520c6e2..dde7e3fc55 100644
--- a/apps/sim/stores/panel/copilot/types.ts
+++ b/apps/sim/stores/panel/copilot/types.ts
@@ -101,6 +101,8 @@ export type ChatContext =
| { kind: 'logs'; executionId?: string; label: string }
| { kind: 'workflow_block'; workflowId: string; blockId: string; label: string }
| { kind: 'knowledge'; knowledgeId?: string; label: string }
+ | { kind: 'table'; tableId: string; label: string }
+ | { kind: 'file'; fileId: string; label: string }
| { kind: 'templates'; templateId?: string; label: string }
| { kind: 'docs'; label: string }
| { kind: 'slash_command'; command: string; label: string }