diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx index d646a179b1..83ff87eb90 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx @@ -8,7 +8,6 @@ import { Button, Code, getCodeEditorProps, highlight, languages } from '@/compon import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool' import { getClientTool } from '@/lib/copilot/tools/client/manager' import { getRegisteredTools } from '@/lib/copilot/tools/client/registry' -// Initialize all tool UI configs import '@/lib/copilot/tools/client/init-tool-configs' import { getSubagentLabels as getSubagentLabelsFromConfig, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/index.ts index bab808a85b..1d0da42d4c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/index.ts @@ -1,6 +1,6 @@ export { AttachedFilesDisplay } from './attached-files-display/attached-files-display' export { ContextPills } from './context-pills/context-pills' -export { MentionMenu } from './mention-menu/mention-menu' +export { type MentionFolderNav, MentionMenu } from './mention-menu/mention-menu' export { ModeSelector } from './mode-selector/mode-selector' export { ModelSelector } from './model-selector/model-selector' -export { SlashMenu } from './slash-menu/slash-menu' +export { type SlashFolderNav, SlashMenu } from './slash-menu/slash-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mention-menu/folder-content.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mention-menu/folder-content.tsx new file mode 100644 index 0000000000..bb45e83d9b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mention-menu/folder-content.tsx @@ -0,0 +1,151 @@ +'use client' + +import type { ComponentType, ReactNode, SVGProps } from 'react' +import { PopoverItem } from '@/components/emcn' +import { formatCompactTimestamp } from '@/lib/core/utils/formatting' +import { + FOLDER_CONFIGS, + MENU_STATE_TEXT_CLASSES, + type MentionFolderId, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' + +const ICON_CONTAINER = + 'relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]' + +export function BlockIcon({ + bgColor, + Icon, +}: { + bgColor?: string + Icon?: ComponentType> +}) { + return ( +
+ {Icon && } +
+ ) +} + +export function WorkflowColorDot({ color }: { color?: string }) { + return
+} + +interface FolderContentProps { + /** Folder ID to render content for */ + folderId: MentionFolderId + /** Items to render (already filtered) */ + items: any[] + /** Whether data is loading */ + isLoading: boolean + /** Current search query (for determining empty vs no-match message) */ + currentQuery: string + /** Currently active item index (for keyboard navigation) */ + activeIndex: number + /** Callback when an item is clicked */ + onItemClick: (item: any) => void +} + +export function renderItemIcon(folderId: MentionFolderId, item: any): ReactNode { + switch (folderId) { + case 'workflows': + return + case 'blocks': + case 'workflow-blocks': + return + default: + return null + } +} + +function renderItemSuffix(folderId: MentionFolderId, item: any): ReactNode { + switch (folderId) { + case 'templates': + return {item.stars} + case 'logs': + return ( + <> + · + + {formatCompactTimestamp(item.createdAt)} + + · + {(item.trigger || 'manual').toLowerCase()} + + ) + default: + return null + } +} + +export function FolderContent({ + folderId, + items, + isLoading, + currentQuery, + activeIndex, + onItemClick, +}: FolderContentProps) { + const config = FOLDER_CONFIGS[folderId] + + if (isLoading) { + return
Loading...
+ } + + if (items.length === 0) { + return ( +
+ {currentQuery ? config.noMatchMessage : config.emptyMessage} +
+ ) + } + + return ( + <> + {items.map((item, index) => ( + onItemClick(item)} + data-idx={index} + active={index === activeIndex} + > + {renderItemIcon(folderId, item)} + + {config.getLabel(item)} + + {renderItemSuffix(folderId, item)} + + ))} + + ) +} + +export function FolderPreviewContent({ + folderId, + items, + isLoading, + onItemClick, +}: Omit) { + const config = FOLDER_CONFIGS[folderId] + + if (isLoading) { + return
Loading...
+ } + + if (items.length === 0) { + return
{config.emptyMessage}
+ } + + return ( + <> + {items.map((item) => ( + onItemClick(item)}> + {renderItemIcon(folderId, item)} + + {config.getLabel(item)} + + {renderItemSuffix(folderId, item)} + + ))} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mention-menu/mention-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mention-menu/mention-menu.tsx index 651e551e25..89dbafa4b2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mention-menu/mention-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mention-menu/mention-menu.tsx @@ -1,6 +1,6 @@ 'use client' -import { useMemo } from 'react' +import { useCallback, useEffect, useMemo } from 'react' import { Popover, PopoverAnchor, @@ -9,47 +9,43 @@ import { PopoverFolder, PopoverItem, PopoverScrollArea, + usePopoverContext, } from '@/components/emcn' -import type { useMentionData } from '../../hooks/use-mention-data' -import type { useMentionMenu } from '../../hooks/use-mention-menu' - -function formatTimestamp(iso: string): string { - try { - const d = new Date(iso) - const mm = String(d.getMonth() + 1).padStart(2, '0') - const dd = String(d.getDate()).padStart(2, '0') - const hh = String(d.getHours()).padStart(2, '0') - const min = String(d.getMinutes()).padStart(2, '0') - return `${mm}-${dd} ${hh}:${min}` - } catch { - return iso - } -} - -const STATE_TEXT_CLASSES = 'px-[8px] py-[8px] text-[12px] text-[var(--text-muted)]' - -const LoadingState = () =>
Loading...
- -const EmptyState = ({ message }: { message: string }) => ( -
{message}
-) +import { formatCompactTimestamp } from '@/lib/core/utils/formatting' +import { + FOLDER_CONFIGS, + FOLDER_ORDER, + MENU_STATE_TEXT_CLASSES, + type MentionCategory, + type MentionFolderId, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' +import { + useCaretViewport, + type useMentionData, + type useMentionMenu, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks' +import { + getFolderData as getFolderDataUtil, + getFolderEnsureLoaded as getFolderEnsureLoadedUtil, + getFolderLoading as getFolderLoadingUtil, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' +import { FolderContent, FolderPreviewContent, renderItemIcon } from './folder-content' interface AggregatedItem { id: string label: string - category: - | 'chats' - | 'workflows' - | 'knowledge' - | 'blocks' - | 'workflow-blocks' - | 'templates' - | 'logs' - | 'docs' + category: MentionCategory data: any icon?: React.ReactNode } +export interface MentionFolderNav { + isInFolder: boolean + currentFolder: string | null + openFolder: (id: string, title: string) => void + closeFolder: () => void +} + interface MentionMenuProps { mentionMenu: ReturnType mentionData: ReturnType @@ -64,170 +60,124 @@ interface MentionMenuProps { insertLogMention: (log: any) => void insertDocsMention: () => void } + onFolderNavChange?: (nav: MentionFolderNav) => void } -export function MentionMenu({ +type InsertHandlerMap = Record void> + +function MentionMenuContent({ mentionMenu, mentionData, message, insertHandlers, + onFolderNavChange, }: MentionMenuProps) { + const { currentFolder, openFolder, closeFolder } = usePopoverContext() + const { - mentionMenuRef, menuListRef, getActiveMentionQueryAtPosition, getCaretPos, submenuActiveIndex, mentionActiveIndex, - openSubmenuFor, - setOpenSubmenuFor, + setSubmenuActiveIndex, } = mentionMenu - const { - insertPastChatMention, - insertWorkflowMention, - insertKnowledgeMention, - insertBlockMention, - insertWorkflowBlockMention, - insertTemplateMention, - insertLogMention, - insertDocsMention, - } = insertHandlers - - /** - * Get the current query string after @ - */ const currentQuery = useMemo(() => { const caretPos = getCaretPos() const active = getActiveMentionQueryAtPosition(caretPos, message) return active?.query.trim().toLowerCase() || '' }, [message, getCaretPos, getActiveMentionQueryAtPosition]) - /** - * Collect and filter all available items based on query - */ - const filteredAggregatedItems = useMemo(() => { - if (!currentQuery) return [] - - const items: AggregatedItem[] = [] + const isInFolder = currentFolder !== null + const showAggregatedView = currentQuery.length > 0 + const isInFolderNavigationMode = !isInFolder && !showAggregatedView + + useEffect(() => { + setSubmenuActiveIndex(0) + }, [isInFolder, setSubmenuActiveIndex]) + + useEffect(() => { + if (onFolderNavChange) { + onFolderNavChange({ + isInFolder, + currentFolder, + openFolder, + closeFolder, + }) + } + }, [onFolderNavChange, isInFolder, currentFolder, openFolder, closeFolder]) + + const insertHandlerMap = useMemo( + (): InsertHandlerMap => ({ + chats: insertHandlers.insertPastChatMention, + workflows: insertHandlers.insertWorkflowMention, + knowledge: insertHandlers.insertKnowledgeMention, + blocks: insertHandlers.insertBlockMention, + 'workflow-blocks': insertHandlers.insertWorkflowBlockMention, + templates: insertHandlers.insertTemplateMention, + logs: insertHandlers.insertLogMention, + }), + [insertHandlers] + ) - // Chats - mentionData.pastChats.forEach((chat) => { - const label = chat.title || 'New Chat' - if (label.toLowerCase().includes(currentQuery)) { - items.push({ - id: `chat-${chat.id}`, - label, - category: 'chats', - data: chat, - }) - } - }) + const getFolderData = useCallback( + (folderId: MentionFolderId) => getFolderDataUtil(mentionData, folderId), + [mentionData] + ) - // Workflows - mentionData.workflows.forEach((wf) => { - const label = wf.name || 'Untitled Workflow' - if (label.toLowerCase().includes(currentQuery)) { - items.push({ - id: `workflow-${wf.id}`, - label, - category: 'workflows', - data: wf, - icon: ( -
- ), - }) - } - }) + const getFolderLoading = useCallback( + (folderId: MentionFolderId) => getFolderLoadingUtil(mentionData, folderId), + [mentionData] + ) - // Knowledge bases - mentionData.knowledgeBases.forEach((kb) => { - const label = kb.name || 'Untitled' - if (label.toLowerCase().includes(currentQuery)) { - items.push({ - id: `knowledge-${kb.id}`, - label, - category: 'knowledge', - data: kb, - }) - } - }) + const getEnsureLoaded = useCallback( + (folderId: MentionFolderId) => getFolderEnsureLoadedUtil(mentionData, folderId), + [mentionData] + ) - // Blocks - mentionData.blocksList.forEach((blk) => { - const label = blk.name || blk.id - if (label.toLowerCase().includes(currentQuery)) { - const Icon = blk.iconComponent - items.push({ - id: `block-${blk.id}`, - label, - category: 'blocks', - data: blk, - icon: ( -
- {Icon && } -
- ), - }) - } - }) + const filterFolderItems = useCallback( + (folderId: MentionFolderId, query: string): any[] => { + const config = FOLDER_CONFIGS[folderId] + const items = getFolderData(folderId) + if (!query) return items + const q = query.toLowerCase() + return items.filter((item) => config.filterFn(item, q)) + }, + [getFolderData] + ) - // Workflow blocks - mentionData.workflowBlocks.forEach((blk) => { - const label = blk.name || blk.id - if (label.toLowerCase().includes(currentQuery)) { - const Icon = blk.iconComponent - items.push({ - id: `workflow-block-${blk.id}`, - label, - category: 'workflow-blocks', - data: blk, - icon: ( -
- {Icon && } -
- ), - }) - } - }) + const getFilteredFolderItems = useCallback( + (folderId: MentionFolderId): any[] => { + return isInFolder ? filterFolderItems(folderId, currentQuery) : getFolderData(folderId) + }, + [isInFolder, currentQuery, filterFolderItems, getFolderData] + ) - // Templates - mentionData.templatesList.forEach((tpl) => { - const label = tpl.name - if (label.toLowerCase().includes(currentQuery)) { - items.push({ - id: `template-${tpl.id}`, - label, - category: 'templates', - data: tpl, - }) - } - }) + const filteredAggregatedItems = useMemo(() => { + if (!currentQuery) return [] - // Logs - mentionData.logsList.forEach((log) => { - const label = log.workflowName - if (label.toLowerCase().includes(currentQuery)) { - items.push({ - id: `log-${log.id}`, - label, - category: 'logs', - data: log, - }) - } - }) + const items: AggregatedItem[] = [] + const q = currentQuery.toLowerCase() + + for (const folderId of FOLDER_ORDER) { + const config = FOLDER_CONFIGS[folderId] + const folderData = getFolderData(folderId) + + folderData.forEach((item) => { + if (config.filterFn(item, q)) { + items.push({ + id: `${folderId}-${config.getId(item)}`, + label: config.getLabel(item), + category: folderId as MentionCategory, + data: item, + icon: renderItemIcon(folderId, item), + }) + } + }) + } - // Docs - if ('docs'.includes(currentQuery)) { + if ('docs'.includes(q)) { items.push({ id: 'docs', label: 'Docs', @@ -237,107 +187,114 @@ export function MentionMenu({ } return items - }, [currentQuery, mentionData]) - - /** - * Handle click on aggregated item - */ - const handleAggregatedItemClick = (item: AggregatedItem) => { - switch (item.category) { - case 'chats': - insertPastChatMention(item.data) - break - case 'workflows': - insertWorkflowMention(item.data) - break - case 'knowledge': - insertKnowledgeMention(item.data) - break - case 'blocks': - insertBlockMention(item.data) - break - case 'workflow-blocks': - insertWorkflowBlockMention(item.data) - break - case 'templates': - insertTemplateMention(item.data) - break - case 'logs': - insertLogMention(item.data) - break - case 'docs': - insertDocsMention() - break - } - } + }, [currentQuery, getFolderData]) - // Open state derived directly from mention menu - const open = !!mentionMenu.showMentionMenu - - // Show filtered aggregated view when there's a query - const showAggregatedView = currentQuery.length > 0 - - // Folder order for keyboard navigation - matches render order - const FOLDER_ORDER = [ - 'Chats', // 0 - 'Workflows', // 1 - 'Knowledge', // 2 - 'Blocks', // 3 - 'Workflow Blocks', // 4 - 'Templates', // 5 - 'Logs', // 6 - 'Docs', // 7 - ] as const + const handleAggregatedItemClick = useCallback( + (item: AggregatedItem) => { + if (item.category === 'docs') { + insertHandlers.insertDocsMention() + return + } + const handler = insertHandlerMap[item.category as MentionFolderId] + if (handler) { + handler(item.data) + } + }, + [insertHandlerMap, insertHandlers] + ) - const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView + return ( + + {isInFolder ? ( + + ) : showAggregatedView ? ( + <> + {filteredAggregatedItems.length === 0 ? ( +
No results found
+ ) : ( + filteredAggregatedItems.map((item, index) => ( + handleAggregatedItemClick(item)} + data-idx={index} + active={index === submenuActiveIndex} + > + {item.icon} + {item.label} + {item.category === 'logs' && ( + <> + · + + {formatCompactTimestamp(item.data.createdAt)} + + + )} + + )) + )} + + ) : ( + <> + {FOLDER_ORDER.map((folderId, folderIndex) => { + const config = FOLDER_CONFIGS[folderId] + const ensureLoaded = getEnsureLoaded(folderId) + + return ( + ensureLoaded?.()} + active={isInFolderNavigationMode && mentionActiveIndex === folderIndex} + data-idx={folderIndex} + > + + + ) + })} + + insertHandlers.insertDocsMention()} + active={isInFolderNavigationMode && mentionActiveIndex === FOLDER_ORDER.length} + data-idx={FOLDER_ORDER.length} + > + Docs + + + )} +
+ ) +} - const textareaEl = mentionMenu.textareaRef.current - if (!textareaEl) return null +export function MentionMenu({ + mentionMenu, + mentionData, + message, + insertHandlers, + onFolderNavChange, +}: MentionMenuProps) { + const { mentionMenuRef, textareaRef, getCaretPos } = mentionMenu const caretPos = getCaretPos() - const textareaRect = textareaEl.getBoundingClientRect() - const style = window.getComputedStyle(textareaEl) - - const mirrorDiv = document.createElement('div') - mirrorDiv.style.position = 'absolute' - mirrorDiv.style.visibility = 'hidden' - mirrorDiv.style.whiteSpace = 'pre-wrap' - mirrorDiv.style.wordWrap = 'break-word' - mirrorDiv.style.font = style.font - mirrorDiv.style.padding = style.padding - mirrorDiv.style.border = style.border - mirrorDiv.style.width = style.width - mirrorDiv.style.lineHeight = style.lineHeight - mirrorDiv.style.boxSizing = style.boxSizing - mirrorDiv.style.letterSpacing = style.letterSpacing - mirrorDiv.style.textTransform = style.textTransform - mirrorDiv.style.textIndent = style.textIndent - mirrorDiv.style.textAlign = style.textAlign - mirrorDiv.textContent = message.substring(0, caretPos) + const { caretViewport, side } = useCaretViewport({ textareaRef, message, caretPos }) - const caretMarker = document.createElement('span') - caretMarker.style.display = 'inline-block' - caretMarker.style.width = '0px' - caretMarker.style.padding = '0' - caretMarker.style.border = '0' - mirrorDiv.appendChild(caretMarker) - - document.body.appendChild(mirrorDiv) - const markerRect = caretMarker.getBoundingClientRect() - const mirrorRect = mirrorDiv.getBoundingClientRect() - document.body.removeChild(mirrorDiv) - - const caretViewport = { - left: textareaRect.left + (markerRect.left - mirrorRect.left) - textareaEl.scrollLeft, - top: textareaRect.top + (markerRect.top - mirrorRect.top) - textareaEl.scrollTop, - } - - const margin = 8 - const spaceBelow = window.innerHeight - caretViewport.top - margin - const side: 'top' | 'bottom' = spaceBelow >= caretViewport.top - margin ? 'bottom' : 'top' + if (!caretViewport) return null return ( - {}}> + {}}>
e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()} + onMouseDown={(e) => e.preventDefault()} > - setOpenSubmenuFor(null)} /> - - {openSubmenuFor ? ( - // Submenu view - showing contents of a specific folder - <> - {openSubmenuFor === 'Chats' && ( - <> - {mentionData.isLoadingPastChats ? ( - - ) : mentionData.pastChats.length === 0 ? ( - - ) : ( - mentionData.pastChats.map((chat, index) => ( - insertPastChatMention(chat)} - data-idx={index} - active={index === submenuActiveIndex} - > - {chat.title || 'New Chat'} - - )) - )} - - )} - {openSubmenuFor === 'Workflows' && ( - <> - {mentionData.isLoadingWorkflows ? ( - - ) : mentionData.workflows.length === 0 ? ( - - ) : ( - mentionData.workflows.map((wf, index) => ( - insertWorkflowMention(wf)} - data-idx={index} - active={index === submenuActiveIndex} - > -
- {wf.name || 'Untitled Workflow'} - - )) - )} - - )} - {openSubmenuFor === 'Knowledge' && ( - <> - {mentionData.isLoadingKnowledge ? ( - - ) : mentionData.knowledgeBases.length === 0 ? ( - - ) : ( - mentionData.knowledgeBases.map((kb, index) => ( - insertKnowledgeMention(kb)} - data-idx={index} - active={index === submenuActiveIndex} - > - {kb.name || 'Untitled'} - - )) - )} - - )} - {openSubmenuFor === 'Blocks' && ( - <> - {mentionData.isLoadingBlocks ? ( - - ) : mentionData.blocksList.length === 0 ? ( - - ) : ( - mentionData.blocksList.map((blk, index) => { - const Icon = blk.iconComponent - return ( - insertBlockMention(blk)} - data-idx={index} - active={index === submenuActiveIndex} - > -
- {Icon && } -
- {blk.name || blk.id} -
- ) - }) - )} - - )} - {openSubmenuFor === 'Workflow Blocks' && ( - <> - {mentionData.isLoadingWorkflowBlocks ? ( - - ) : mentionData.workflowBlocks.length === 0 ? ( - - ) : ( - mentionData.workflowBlocks.map((blk, index) => { - const Icon = blk.iconComponent - return ( - insertWorkflowBlockMention(blk)} - data-idx={index} - active={index === submenuActiveIndex} - > -
- {Icon && } -
- {blk.name || blk.id} -
- ) - }) - )} - - )} - {openSubmenuFor === 'Templates' && ( - <> - {mentionData.isLoadingTemplates ? ( - - ) : mentionData.templatesList.length === 0 ? ( - - ) : ( - mentionData.templatesList.map((tpl, index) => ( - insertTemplateMention(tpl)} - data-idx={index} - active={index === submenuActiveIndex} - > - {tpl.name} - {tpl.stars} - - )) - )} - - )} - {openSubmenuFor === 'Logs' && ( - <> - {mentionData.isLoadingLogs ? ( - - ) : mentionData.logsList.length === 0 ? ( - - ) : ( - mentionData.logsList.map((log, index) => ( - insertLogMention(log)} - data-idx={index} - active={index === submenuActiveIndex} - > - {log.workflowName} - · - - {formatTimestamp(log.createdAt)} - - · - - {(log.trigger || 'manual').toLowerCase()} - - - )) - )} - - )} - - ) : showAggregatedView ? ( - // Aggregated filtered view - <> - {filteredAggregatedItems.length === 0 ? ( - - ) : ( - filteredAggregatedItems.map((item, index) => ( - handleAggregatedItemClick(item)} - data-idx={index} - active={index === submenuActiveIndex} - > - {item.icon} - {item.label} - {item.category === 'logs' && ( - <> - · - - {formatTimestamp(item.data.createdAt)} - - - )} - - )) - )} - - ) : ( - // Folder navigation view - <> - mentionData.ensurePastChatsLoaded()} - active={isInFolderNavigationMode && mentionActiveIndex === 0} - data-idx={0} - > - {mentionData.isLoadingPastChats ? ( - - ) : mentionData.pastChats.length === 0 ? ( - - ) : ( - mentionData.pastChats.map((chat) => ( - insertPastChatMention(chat)}> - {chat.title || 'New Chat'} - - )) - )} - - - mentionData.ensureWorkflowsLoaded()} - active={isInFolderNavigationMode && mentionActiveIndex === 1} - data-idx={1} - > - {mentionData.isLoadingWorkflows ? ( - - ) : mentionData.workflows.length === 0 ? ( - - ) : ( - mentionData.workflows.map((wf) => ( - insertWorkflowMention(wf)}> -
- {wf.name || 'Untitled Workflow'} - - )) - )} - - - mentionData.ensureKnowledgeLoaded()} - active={isInFolderNavigationMode && mentionActiveIndex === 2} - data-idx={2} - > - {mentionData.isLoadingKnowledge ? ( - - ) : mentionData.knowledgeBases.length === 0 ? ( - - ) : ( - mentionData.knowledgeBases.map((kb) => ( - insertKnowledgeMention(kb)}> - {kb.name || 'Untitled'} - - )) - )} - - - mentionData.ensureBlocksLoaded()} - active={isInFolderNavigationMode && mentionActiveIndex === 3} - data-idx={3} - > - {mentionData.isLoadingBlocks ? ( - - ) : mentionData.blocksList.length === 0 ? ( - - ) : ( - mentionData.blocksList.map((blk) => { - const Icon = blk.iconComponent - return ( - insertBlockMention(blk)}> -
- {Icon && } -
- {blk.name || blk.id} -
- ) - }) - )} -
- - mentionData.ensureWorkflowBlocksLoaded()} - active={isInFolderNavigationMode && mentionActiveIndex === 4} - data-idx={4} - > - {mentionData.isLoadingWorkflowBlocks ? ( - - ) : mentionData.workflowBlocks.length === 0 ? ( - - ) : ( - mentionData.workflowBlocks.map((blk) => { - const Icon = blk.iconComponent - return ( - insertWorkflowBlockMention(blk)}> -
- {Icon && } -
- {blk.name || blk.id} -
- ) - }) - )} -
- - mentionData.ensureTemplatesLoaded()} - active={isInFolderNavigationMode && mentionActiveIndex === 5} - data-idx={5} - > - {mentionData.isLoadingTemplates ? ( - - ) : mentionData.templatesList.length === 0 ? ( - - ) : ( - mentionData.templatesList.map((tpl) => ( - insertTemplateMention(tpl)}> - {tpl.name} - {tpl.stars} - - )) - )} - - - mentionData.ensureLogsLoaded()} - active={isInFolderNavigationMode && mentionActiveIndex === 6} - data-idx={6} - > - {mentionData.isLoadingLogs ? ( - - ) : mentionData.logsList.length === 0 ? ( - - ) : ( - mentionData.logsList.map((log) => ( - insertLogMention(log)}> - {log.workflowName} - · - - {formatTimestamp(log.createdAt)} - - · - - {(log.trigger || 'manual').toLowerCase()} - - - )) - )} - - - insertDocsMention()} - active={isInFolderNavigationMode && mentionActiveIndex === 7} - data-idx={7} - > - Docs - - - )} - + + ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx index 0e6a79588c..a51067057f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx @@ -1,6 +1,6 @@ 'use client' -import { useMemo } from 'react' +import { useEffect, useMemo } from 'react' import { Popover, PopoverAnchor, @@ -9,51 +9,57 @@ import { PopoverFolder, PopoverItem, PopoverScrollArea, + usePopoverContext, } from '@/components/emcn' +import { + ALL_SLASH_COMMANDS, + MENU_STATE_TEXT_CLASSES, + TOP_LEVEL_COMMANDS, + WEB_COMMANDS, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' +import { useCaretViewport } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks' import type { useMentionMenu } from '../../hooks/use-mention-menu' -const TOP_LEVEL_COMMANDS = [ - { id: 'fast', label: 'Fast' }, - { id: 'research', label: 'Research' }, - { id: 'superagent', label: 'Actions' }, -] as const - -const WEB_COMMANDS = [ - { id: 'search', label: 'Search' }, - { id: 'read', label: 'Read' }, - { id: 'scrape', label: 'Scrape' }, - { id: 'crawl', label: 'Crawl' }, -] as const - -const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS] +export interface SlashFolderNav { + isInFolder: boolean + openWebFolder: () => void + closeFolder: () => void +} interface SlashMenuProps { mentionMenu: ReturnType message: string onSelectCommand: (command: string) => void + onFolderNavChange?: (nav: SlashFolderNav) => void } -export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuProps) { +function SlashMenuContent({ + mentionMenu, + message, + onSelectCommand, + onFolderNavChange, +}: SlashMenuProps) { + const { currentFolder, openFolder, closeFolder } = usePopoverContext() + const { - mentionMenuRef, menuListRef, getActiveSlashQueryAtPosition, getCaretPos, submenuActiveIndex, mentionActiveIndex, - openSubmenuFor, - setOpenSubmenuFor, + setSubmenuActiveIndex, } = mentionMenu + const caretPos = getCaretPos() + const currentQuery = useMemo(() => { - const caretPos = getCaretPos() const active = getActiveSlashQueryAtPosition(caretPos, message) return active?.query.trim().toLowerCase() || '' - }, [message, getCaretPos, getActiveSlashQueryAtPosition]) + }, [message, caretPos, getActiveSlashQueryAtPosition]) const filteredCommands = useMemo(() => { if (!currentQuery) return null - return ALL_COMMANDS.filter( + return ALL_SLASH_COMMANDS.filter( (cmd) => cmd.id.toLowerCase().includes(currentQuery) || cmd.label.toLowerCase().includes(currentQuery) @@ -61,52 +67,106 @@ export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuPr }, [currentQuery]) const showAggregatedView = currentQuery.length > 0 - const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView + const isInFolder = currentFolder !== null + const isInFolderNavigationMode = !isInFolder && !showAggregatedView + + useEffect(() => { + if (onFolderNavChange) { + onFolderNavChange({ + isInFolder, + openWebFolder: () => { + openFolder('web', 'Web') + setSubmenuActiveIndex(0) + }, + closeFolder: () => { + closeFolder() + setSubmenuActiveIndex(0) + }, + }) + } + }, [onFolderNavChange, isInFolder, openFolder, closeFolder, setSubmenuActiveIndex]) + + return ( + + {isInFolder ? ( + <> + {WEB_COMMANDS.map((cmd, index) => ( + onSelectCommand(cmd.id)} + data-idx={index} + active={index === submenuActiveIndex} + > + {cmd.label} + + ))} + + ) : showAggregatedView ? ( + <> + {filteredCommands && filteredCommands.length === 0 ? ( +
No commands found
+ ) : ( + filteredCommands?.map((cmd, index) => ( + onSelectCommand(cmd.id)} + data-idx={index} + active={index === submenuActiveIndex} + > + {cmd.label} + + )) + )} + + ) : ( + <> + {TOP_LEVEL_COMMANDS.map((cmd, index) => ( + onSelectCommand(cmd.id)} + data-idx={index} + active={isInFolderNavigationMode && index === mentionActiveIndex} + > + {cmd.label} + + ))} + + setSubmenuActiveIndex(0)} + active={isInFolderNavigationMode && mentionActiveIndex === TOP_LEVEL_COMMANDS.length} + data-idx={TOP_LEVEL_COMMANDS.length} + > + {WEB_COMMANDS.map((cmd) => ( + onSelectCommand(cmd.id)}> + {cmd.label} + + ))} + + + )} +
+ ) +} - const textareaEl = mentionMenu.textareaRef.current - if (!textareaEl) return null +export function SlashMenu({ + mentionMenu, + message, + onSelectCommand, + onFolderNavChange, +}: SlashMenuProps) { + const { mentionMenuRef, textareaRef, getCaretPos } = mentionMenu const caretPos = getCaretPos() - const textareaRect = textareaEl.getBoundingClientRect() - const style = window.getComputedStyle(textareaEl) - - const mirrorDiv = document.createElement('div') - mirrorDiv.style.position = 'absolute' - mirrorDiv.style.visibility = 'hidden' - mirrorDiv.style.whiteSpace = 'pre-wrap' - mirrorDiv.style.wordWrap = 'break-word' - mirrorDiv.style.font = style.font - mirrorDiv.style.padding = style.padding - mirrorDiv.style.border = style.border - mirrorDiv.style.width = style.width - mirrorDiv.style.lineHeight = style.lineHeight - mirrorDiv.style.boxSizing = style.boxSizing - mirrorDiv.style.letterSpacing = style.letterSpacing - mirrorDiv.style.textTransform = style.textTransform - mirrorDiv.style.textIndent = style.textIndent - mirrorDiv.style.textAlign = style.textAlign - mirrorDiv.textContent = message.substring(0, caretPos) - - const caretMarker = document.createElement('span') - caretMarker.style.display = 'inline-block' - caretMarker.style.width = '0px' - caretMarker.style.padding = '0' - caretMarker.style.border = '0' - mirrorDiv.appendChild(caretMarker) - - document.body.appendChild(mirrorDiv) - const markerRect = caretMarker.getBoundingClientRect() - const mirrorRect = mirrorDiv.getBoundingClientRect() - document.body.removeChild(mirrorDiv) - - const caretViewport = { - left: textareaRect.left + (markerRect.left - mirrorRect.left) - textareaEl.scrollLeft, - top: textareaRect.top + (markerRect.top - mirrorRect.top) - textareaEl.scrollTop, - } - - const margin = 8 - const spaceBelow = window.innerHeight - caretViewport.top - margin - const side: 'top' | 'bottom' = spaceBelow >= caretViewport.top - margin ? 'bottom' : 'top' + + const { caretViewport, side } = useCaretViewport({ + textareaRef, + message, + caretPos, + }) + + if (!caretViewport) return null return ( {}}> @@ -129,77 +189,18 @@ export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuPr collisionPadding={6} maxHeight={360} className='pointer-events-auto' - style={{ - width: `180px`, - }} + style={{ width: '180px' }} onOpenAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()} + onMouseDown={(e) => e.preventDefault()} > - setOpenSubmenuFor(null)} /> - - {openSubmenuFor === 'Web' ? ( - <> - {WEB_COMMANDS.map((cmd, index) => ( - onSelectCommand(cmd.id)} - data-idx={index} - active={index === submenuActiveIndex} - > - {cmd.label} - - ))} - - ) : showAggregatedView ? ( - <> - {filteredCommands && filteredCommands.length === 0 ? ( -
- No commands found -
- ) : ( - filteredCommands?.map((cmd, index) => ( - onSelectCommand(cmd.id)} - data-idx={index} - active={index === submenuActiveIndex} - > - {cmd.label} - - )) - )} - - ) : ( - <> - {TOP_LEVEL_COMMANDS.map((cmd, index) => ( - onSelectCommand(cmd.id)} - data-idx={index} - active={isInFolderNavigationMode && index === mentionActiveIndex} - > - {cmd.label} - - ))} - - setOpenSubmenuFor('Web')} - active={ - isInFolderNavigationMode && mentionActiveIndex === TOP_LEVEL_COMMANDS.length - } - data-idx={TOP_LEVEL_COMMANDS.length} - > - {WEB_COMMANDS.map((cmd) => ( - onSelectCommand(cmd.id)}> - {cmd.label} - - ))} - - - )} -
+ +
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts index 5872144aae..74c5f275a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts @@ -1,42 +1,245 @@ +import type { ChatContext } from '@/stores/panel' + /** - * Constants for user input component + * Mention folder types */ +export type MentionFolderId = + | 'chats' + | 'workflows' + | 'knowledge' + | 'blocks' + | 'workflow-blocks' + | 'templates' + | 'logs' /** - * Mention menu options in order (matches visual render order) + * Menu item category types for mention menu (includes folders + docs item) */ -export const MENTION_OPTIONS = [ - 'Chats', - 'Workflows', - 'Knowledge', - 'Blocks', - 'Workflow Blocks', - 'Templates', - 'Logs', - 'Docs', +export type MentionCategory = MentionFolderId | 'docs' + +/** + * Configuration interface for folder types + */ +export interface FolderConfig { + /** Display title in menu */ + title: string + /** Data source key in useMentionData return */ + dataKey: string + /** Loading state key in useMentionData return */ + loadingKey: string + /** Ensure loaded function key in useMentionData return (optional - some folders auto-load) */ + ensureLoadedKey?: string + /** Extract label from an item */ + getLabel: (item: TItem) => string + /** Extract unique ID from an item */ + getId: (item: TItem) => string + /** Empty state message */ + emptyMessage: string + /** No match message (when filtering) */ + noMatchMessage: string + /** Filter function for matching query */ + filterFn: (item: TItem, query: string) => boolean + /** Build the ChatContext object from an item */ + buildContext: (item: TItem, workflowId?: string | null) => ChatContext + /** Whether to use insertAtCursor fallback when replaceActiveMentionWith fails */ + useInsertFallback?: boolean +} + +/** + * Configuration for all folder types in the mention menu + */ +export const FOLDER_CONFIGS: Record = { + chats: { + title: 'Chats', + dataKey: 'pastChats', + loadingKey: 'isLoadingPastChats', + ensureLoadedKey: 'ensurePastChatsLoaded', + getLabel: (item) => item.title || 'New Chat', + getId: (item) => item.id, + emptyMessage: 'No past chats', + noMatchMessage: 'No matching chats', + filterFn: (item, q) => (item.title || 'New Chat').toLowerCase().includes(q), + buildContext: (item) => ({ + kind: 'past_chat', + chatId: item.id, + label: item.title || 'New Chat', + }), + useInsertFallback: false, + }, + workflows: { + title: 'All workflows', + dataKey: 'workflows', + loadingKey: 'isLoadingWorkflows', + // No ensureLoadedKey - workflows auto-load from registry store + getLabel: (item) => item.name || 'Untitled Workflow', + getId: (item) => item.id, + emptyMessage: 'No workflows', + noMatchMessage: 'No matching workflows', + filterFn: (item, q) => (item.name || 'Untitled Workflow').toLowerCase().includes(q), + buildContext: (item) => ({ + kind: 'workflow', + workflowId: item.id, + label: item.name || 'Untitled Workflow', + }), + useInsertFallback: true, + }, + knowledge: { + title: 'Knowledge Bases', + dataKey: 'knowledgeBases', + loadingKey: 'isLoadingKnowledge', + ensureLoadedKey: 'ensureKnowledgeLoaded', + getLabel: (item) => item.name || 'Untitled', + getId: (item) => item.id, + emptyMessage: 'No knowledge bases', + noMatchMessage: 'No matching knowledge bases', + filterFn: (item, q) => (item.name || 'Untitled').toLowerCase().includes(q), + buildContext: (item) => ({ + kind: 'knowledge', + knowledgeId: item.id, + label: item.name || 'Untitled', + }), + useInsertFallback: false, + }, + blocks: { + title: 'Blocks', + dataKey: 'blocksList', + loadingKey: 'isLoadingBlocks', + ensureLoadedKey: 'ensureBlocksLoaded', + getLabel: (item) => item.name || item.id, + getId: (item) => item.id, + emptyMessage: 'No blocks found', + noMatchMessage: 'No matching blocks', + filterFn: (item, q) => (item.name || item.id).toLowerCase().includes(q), + buildContext: (item) => ({ + kind: 'blocks', + blockIds: [item.id], + label: item.name || item.id, + }), + useInsertFallback: false, + }, + 'workflow-blocks': { + title: 'Workflow Blocks', + dataKey: 'workflowBlocks', + loadingKey: 'isLoadingWorkflowBlocks', + // No ensureLoadedKey - workflow blocks auto-sync from store + getLabel: (item) => item.name || item.id, + getId: (item) => item.id, + emptyMessage: 'No blocks in this workflow', + noMatchMessage: 'No matching blocks', + filterFn: (item, q) => (item.name || item.id).toLowerCase().includes(q), + buildContext: (item, workflowId) => ({ + kind: 'workflow_block', + workflowId: workflowId || '', + blockId: item.id, + label: item.name || item.id, + }), + useInsertFallback: true, + }, + templates: { + title: 'Templates', + dataKey: 'templatesList', + loadingKey: 'isLoadingTemplates', + ensureLoadedKey: 'ensureTemplatesLoaded', + getLabel: (item) => item.name || 'Untitled Template', + getId: (item) => item.id, + emptyMessage: 'No templates found', + noMatchMessage: 'No matching templates', + filterFn: (item, q) => (item.name || 'Untitled Template').toLowerCase().includes(q), + buildContext: (item) => ({ + kind: 'templates', + templateId: item.id, + label: item.name || 'Untitled Template', + }), + useInsertFallback: false, + }, + logs: { + title: 'Logs', + dataKey: 'logsList', + loadingKey: 'isLoadingLogs', + ensureLoadedKey: 'ensureLogsLoaded', + getLabel: (item) => item.workflowName, + getId: (item) => item.id, + emptyMessage: 'No executions found', + noMatchMessage: 'No matching executions', + filterFn: (item, q) => + [item.workflowName, item.trigger || ''].join(' ').toLowerCase().includes(q), + buildContext: (item) => ({ + kind: 'logs', + executionId: item.executionId || item.id, + label: item.workflowName, + }), + useInsertFallback: false, + }, +} + +/** + * Order of folders in the mention menu + */ +export const FOLDER_ORDER: MentionFolderId[] = [ + 'chats', + 'workflows', + 'knowledge', + 'blocks', + 'workflow-blocks', + 'templates', + 'logs', +] + +/** + * Docs item configuration (special case - not a folder) + */ +export const DOCS_CONFIG = { + getLabel: () => 'Docs', + buildContext: (): ChatContext => ({ kind: 'docs', label: 'Docs' }), +} as const + +/** + * Total number of items in root menu (folders + docs) + */ +export const ROOT_MENU_ITEM_COUNT = FOLDER_ORDER.length + 1 + +/** + * Slash command configuration + */ +export interface SlashCommand { + id: string + label: string +} + +export const TOP_LEVEL_COMMANDS: readonly SlashCommand[] = [ + { id: 'fast', label: 'Fast' }, + { id: 'research', label: 'Research' }, + { id: 'superagent', label: 'Actions' }, +] as const + +export const WEB_COMMANDS: readonly SlashCommand[] = [ + { id: 'search', label: 'Search' }, + { id: 'read', label: 'Read' }, + { id: 'scrape', label: 'Scrape' }, + { id: 'crawl', label: 'Crawl' }, ] as const +export const ALL_SLASH_COMMANDS: readonly SlashCommand[] = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS] + +export const ALL_COMMAND_IDS = ALL_SLASH_COMMANDS.map((cmd) => cmd.id) + +/** + * Get display label for a command ID + */ +export function getCommandDisplayLabel(commandId: string): string { + const command = ALL_SLASH_COMMANDS.find((cmd) => cmd.id === commandId) + return command?.label || commandId.charAt(0).toUpperCase() + commandId.slice(1) +} + /** * Model configuration options */ export const MODEL_OPTIONS = [ { value: 'claude-4.5-opus', label: 'Claude 4.5 Opus' }, { value: 'claude-4.5-sonnet', label: 'Claude 4.5 Sonnet' }, - // { value: 'claude-4-sonnet', label: 'Claude 4 Sonnet' }, { value: 'claude-4.5-haiku', label: 'Claude 4.5 Haiku' }, - // { value: 'claude-4.1-opus', label: 'Claude 4.1 Opus' }, { value: 'gpt-5.1-codex', label: 'GPT 5.1 Codex' }, - // { value: 'gpt-5-codex', label: 'GPT 5 Codex' }, { value: 'gpt-5.1-medium', label: 'GPT 5.1 Medium' }, - // { value: 'gpt-5-fast', label: 'GPT 5 Fast' }, - // { value: 'gpt-5', label: 'GPT 5' }, - // { value: 'gpt-5.1-fast', label: 'GPT 5.1 Fast' }, - // { value: 'gpt-5.1', label: 'GPT 5.1' }, - // { value: 'gpt-5.1-high', label: 'GPT 5.1 High' }, - // { value: 'gpt-5-high', label: 'GPT 5 High' }, - // { value: 'gpt-4o', label: 'GPT 4o' }, - // { value: 'gpt-4.1', label: 'GPT 4.1' }, - // { value: 'o3', label: 'o3' }, { value: 'gemini-3-pro', label: 'Gemini 3 Pro' }, ] as const @@ -49,3 +252,18 @@ export const NEAR_TOP_THRESHOLD = 300 * Scroll tolerance for mention menu positioning (in pixels) */ export const SCROLL_TOLERANCE = 8 + +/** + * Shared CSS classes for menu state text (loading, empty states) + */ +export const MENU_STATE_TEXT_CLASSES = 'px-[8px] py-[8px] text-[12px] text-[var(--text-muted)]' + +/** + * Calculates the next index for circular navigation (wraps around at bounds) + */ +export function getNextIndex(current: number, direction: 'up' | 'down', maxIndex: number): number { + if (direction === 'down') { + return current >= maxIndex ? 0 : current + 1 + } + return current <= 0 ? maxIndex : current - 1 +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/index.ts index 6631a13c75..858a39c136 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/index.ts @@ -1,3 +1,4 @@ +export { useCaretViewport } from './use-caret-viewport' export { useContextManagement } from './use-context-management' export { useFileAttachments } from './use-file-attachments' export { useMentionData } from './use-mention-data' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-caret-viewport.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-caret-viewport.ts new file mode 100644 index 0000000000..51cc921228 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-caret-viewport.ts @@ -0,0 +1,77 @@ +import { useMemo } from 'react' + +interface CaretViewportPosition { + left: number + top: number +} + +interface UseCaretViewportResult { + caretViewport: CaretViewportPosition | null + side: 'top' | 'bottom' +} + +interface UseCaretViewportProps { + textareaRef: React.RefObject + message: string + caretPos: number +} + +/** + * Calculates the viewport position of the caret in a textarea using the mirror div technique. + * This hook memoizes the calculation to prevent unnecessary DOM manipulation on every render. + */ +export function useCaretViewport({ + textareaRef, + message, + caretPos, +}: UseCaretViewportProps): UseCaretViewportResult { + return useMemo(() => { + const textareaEl = textareaRef.current + if (!textareaEl) { + return { caretViewport: null, side: 'bottom' as const } + } + + const textareaRect = textareaEl.getBoundingClientRect() + const style = window.getComputedStyle(textareaEl) + + const mirrorDiv = document.createElement('div') + mirrorDiv.style.position = 'absolute' + mirrorDiv.style.visibility = 'hidden' + mirrorDiv.style.whiteSpace = 'pre-wrap' + mirrorDiv.style.overflowWrap = 'break-word' + mirrorDiv.style.font = style.font + mirrorDiv.style.padding = style.padding + mirrorDiv.style.border = style.border + mirrorDiv.style.width = style.width + mirrorDiv.style.lineHeight = style.lineHeight + mirrorDiv.style.boxSizing = style.boxSizing + mirrorDiv.style.letterSpacing = style.letterSpacing + mirrorDiv.style.textTransform = style.textTransform + mirrorDiv.style.textIndent = style.textIndent + mirrorDiv.style.textAlign = style.textAlign + mirrorDiv.textContent = message.substring(0, caretPos) + + const caretMarker = document.createElement('span') + caretMarker.style.display = 'inline-block' + caretMarker.style.width = '0px' + caretMarker.style.padding = '0' + caretMarker.style.border = '0' + mirrorDiv.appendChild(caretMarker) + + document.body.appendChild(mirrorDiv) + const markerRect = caretMarker.getBoundingClientRect() + const mirrorRect = mirrorDiv.getBoundingClientRect() + document.body.removeChild(mirrorDiv) + + const caretViewport = { + left: textareaRect.left + (markerRect.left - mirrorRect.left) - textareaEl.scrollLeft, + top: textareaRect.top + (markerRect.top - mirrorRect.top) - textareaEl.scrollTop, + } + + const margin = 8 + const spaceBelow = window.innerHeight - caretViewport.top - margin + const side: 'top' | 'bottom' = spaceBelow >= caretViewport.top - margin ? 'bottom' : 'top' + + return { caretViewport, side } + }, [textareaRef, message, caretPos]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts index 9e85bbeca6..6b062e13f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts @@ -1,4 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { + filterOutContext, + isContextAlreadySelected, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' import type { ChatContext } from '@/stores/panel' interface UseContextManagementProps { @@ -35,53 +39,7 @@ export function useContextManagement({ message, initialContexts }: UseContextMan */ const addContext = useCallback((context: ChatContext) => { setSelectedContexts((prev) => { - // CRITICAL: Check label collision FIRST - // The token system uses @label format, so we cannot have duplicate labels - // regardless of kind or ID differences - const exists = prev.some((c) => { - // Primary check: label collision - // This prevents duplicate @Label tokens which would break the overlay - if (c.label && context.label && c.label === context.label) { - return true - } - - // Secondary check: exact duplicate by ID fields based on kind - // This prevents the same entity from being added twice even with different labels - if (c.kind === context.kind) { - if (c.kind === 'past_chat' && 'chatId' in context && 'chatId' in c) { - return c.chatId === (context as any).chatId - } - if (c.kind === 'workflow' && 'workflowId' in context && 'workflowId' in c) { - return c.workflowId === (context as any).workflowId - } - if (c.kind === 'blocks' && 'blockId' in context && 'blockId' in c) { - return c.blockId === (context as any).blockId - } - if (c.kind === 'workflow_block' && 'blockId' in context && 'blockId' in c) { - return ( - c.workflowId === (context as any).workflowId && c.blockId === (context as any).blockId - ) - } - if (c.kind === 'knowledge' && 'knowledgeId' in context && 'knowledgeId' in c) { - return c.knowledgeId === (context as any).knowledgeId - } - if (c.kind === 'templates' && 'templateId' in context && 'templateId' in c) { - return c.templateId === (context as any).templateId - } - if (c.kind === 'logs' && 'executionId' in context && 'executionId' in c) { - return c.executionId === (context as any).executionId - } - if (c.kind === 'docs') { - return true // Only one docs context allowed - } - if (c.kind === 'slash_command' && 'command' in context && 'command' in c) { - return c.command === (context as any).command - } - } - - return false - }) - if (exists) return prev + if (isContextAlreadySelected(context, prev)) return prev return [...prev, context] }) }, []) @@ -92,38 +50,7 @@ export function useContextManagement({ message, initialContexts }: UseContextMan * @param contextToRemove - Context to remove */ const removeContext = useCallback((contextToRemove: ChatContext) => { - setSelectedContexts((prev) => - prev.filter((c) => { - // Match by kind and specific ID fields - if (c.kind !== contextToRemove.kind) return true - - switch (c.kind) { - case 'past_chat': - return (c as any).chatId !== (contextToRemove as any).chatId - case 'workflow': - return (c as any).workflowId !== (contextToRemove as any).workflowId - case 'blocks': - return (c as any).blockId !== (contextToRemove as any).blockId - case 'workflow_block': - return ( - (c as any).workflowId !== (contextToRemove as any).workflowId || - (c as any).blockId !== (contextToRemove as any).blockId - ) - case 'knowledge': - return (c as any).knowledgeId !== (contextToRemove as any).knowledgeId - case 'templates': - return (c as any).templateId !== (contextToRemove as any).templateId - case 'logs': - return (c as any).executionId !== (contextToRemove as any).executionId - case 'docs': - return false // Remove docs (only one docs context) - case 'slash_command': - return (c as any).command !== (contextToRemove as any).command - default: - return c.label !== contextToRemove.label - } - }) - ) + setSelectedContexts((prev) => filterOutContext(prev, contextToRemove)) }, []) /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts index 476623e8b7..cea9a5ce34 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts @@ -83,6 +83,36 @@ interface UseMentionDataProps { workspaceId: string } +/** + * Return type for useMentionData hook + */ +export interface MentionDataReturn { + // Data arrays + pastChats: PastChat[] + workflows: WorkflowItem[] + knowledgeBases: KnowledgeItem[] + blocksList: BlockItem[] + workflowBlocks: WorkflowBlockItem[] + templatesList: TemplateItem[] + logsList: LogItem[] + + // Loading states + isLoadingPastChats: boolean + isLoadingWorkflows: boolean + isLoadingKnowledge: boolean + isLoadingBlocks: boolean + isLoadingWorkflowBlocks: boolean + isLoadingTemplates: boolean + isLoadingLogs: boolean + + // Ensure loaded functions + ensurePastChatsLoaded: () => Promise + ensureKnowledgeLoaded: () => Promise + ensureBlocksLoaded: () => Promise + ensureTemplatesLoaded: () => Promise + ensureLogsLoaded: () => Promise +} + /** * Custom hook to fetch and manage data for mention suggestions * Loads data from APIs for chats, workflows, knowledge bases, blocks, templates, and logs @@ -90,7 +120,7 @@ interface UseMentionDataProps { * @param props - Configuration including workflow and workspace IDs * @returns Mention data state and loading operations */ -export function useMentionData(props: UseMentionDataProps) { +export function useMentionData(props: UseMentionDataProps): MentionDataReturn { const { workflowId, workspaceId } = props const { config, isBlockAllowed } = usePermissionConfig() @@ -104,7 +134,6 @@ export function useMentionData(props: UseMentionDataProps) { const [blocksList, setBlocksList] = useState([]) const [isLoadingBlocks, setIsLoadingBlocks] = useState(false) - // Reset blocks list when permission config changes useEffect(() => { setBlocksList([]) }, [config.allowedIntegrations]) @@ -118,12 +147,10 @@ export function useMentionData(props: UseMentionDataProps) { const [workflowBlocks, setWorkflowBlocks] = useState([]) const [isLoadingWorkflowBlocks, setIsLoadingWorkflowBlocks] = useState(false) - // Only subscribe to block keys to avoid re-rendering on position updates const blockKeys = useWorkflowStore( useShallow(useCallback((state) => Object.keys(state.blocks), [])) ) - // Use workflow registry as source of truth for workflows const registryWorkflows = useWorkflowRegistry((state) => state.workflows) const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase) const isLoadingWorkflows = @@ -131,7 +158,6 @@ export function useMentionData(props: UseMentionDataProps) { hydrationPhase === 'metadata-loading' || hydrationPhase === 'state-loading' - // Convert registry workflows to mention format, filtered by workspace and sorted const workflows: WorkflowItem[] = Object.values(registryWorkflows) .filter((w) => w.workspaceId === workspaceId) .sort((a, b) => { @@ -219,14 +245,6 @@ export function useMentionData(props: UseMentionDataProps) { } }, [isLoadingPastChats, pastChats.length, workflowId]) - /** - * Ensures workflows are loaded (now using registry store) - */ - const ensureWorkflowsLoaded = useCallback(() => { - // Workflows are now automatically loaded from the registry store - // No manual fetching needed - }, []) - /** * Ensures knowledge bases are loaded */ @@ -348,18 +366,6 @@ export function useMentionData(props: UseMentionDataProps) { } }, [isLoadingLogs, logsList.length, workspaceId]) - /** - * Ensures workflow blocks are loaded (synced from store) - */ - const ensureWorkflowBlocksLoaded = useCallback(async () => { - if (!workflowId) return - logger.debug('ensureWorkflowBlocksLoaded called', { - workflowId, - storeBlocksCount: blockKeys.length, - workflowBlocksCount: workflowBlocks.length, - }) - }, [workflowId, blockKeys.length, workflowBlocks.length]) - return { // State pastChats, @@ -379,11 +385,9 @@ export function useMentionData(props: UseMentionDataProps) { // Operations ensurePastChatsLoaded, - ensureWorkflowsLoaded, ensureKnowledgeLoaded, ensureBlocksLoaded, ensureTemplatesLoaded, ensureLogsLoaded, - ensureWorkflowBlocksLoaded, } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers.ts index cd631781d7..478331b3c7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers.ts @@ -1,5 +1,12 @@ -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' +import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components' +import { + DOCS_CONFIG, + FOLDER_CONFIGS, + type FolderConfig, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' import type { useMentionMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu' +import { isContextAlreadySelected } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' import type { ChatContext } from '@/stores/panel' interface UseMentionInsertHandlersProps { @@ -11,12 +18,12 @@ interface UseMentionInsertHandlersProps { selectedContexts: ChatContext[] /** Callback to update selected contexts */ onContextAdd: (context: ChatContext) => void + /** Folder navigation state exposed from MentionMenu via callback */ + mentionFolderNav?: MentionFolderNav | null } /** * Custom hook to provide insert handlers for different mention types. - * Consolidates the logic for inserting mentions and updating selected contexts. - * Prevents duplicate mentions from being inserted. * * @param props - Configuration object * @returns Insert handler functions for each mention type @@ -26,6 +33,7 @@ export function useMentionInsertHandlers({ workflowId, selectedContexts, onContextAdd, + mentionFolderNav, }: UseMentionInsertHandlersProps) { const { replaceActiveMentionWith, @@ -36,342 +44,94 @@ export function useMentionInsertHandlers({ } = mentionMenu /** - * Checks if a context already exists in selected contexts - * CRITICAL: Prioritizes label checking to prevent token system breakage - * - * @param context - Context to check - * @returns True if context already exists or label is already used + * Closes all menus and resets state */ - const isContextAlreadySelected = useCallback( - (context: ChatContext): boolean => { - return selectedContexts.some((c) => { - // CRITICAL: Check label collision FIRST - // The token system uses @label format, so we cannot have duplicate labels - // regardless of kind or ID differences - if (c.label && context.label && c.label === context.label) { - return true + const closeMenus = useCallback(() => { + setShowMentionMenu(false) + if (mentionFolderNav?.isInFolder) { + mentionFolderNav.closeFolder() + } + setOpenSubmenuFor(null) + }, [setShowMentionMenu, setOpenSubmenuFor, mentionFolderNav]) + + const createInsertHandler = useCallback( + (config: FolderConfig) => { + return (item: TItem) => { + const label = config.getLabel(item) + const context = config.buildContext(item, workflowId) + + if (isContextAlreadySelected(context, selectedContexts)) { + resetActiveMentionQuery() + closeMenus() + return } - // Secondary check: exact duplicate by ID fields - if (c.kind === context.kind) { - if (c.kind === 'past_chat' && 'chatId' in context && 'chatId' in c) { - return c.chatId === (context as any).chatId - } - if (c.kind === 'workflow' && 'workflowId' in context && 'workflowId' in c) { - return c.workflowId === (context as any).workflowId - } - if (c.kind === 'blocks' && 'blockId' in context && 'blockId' in c) { - return c.blockId === (context as any).blockId - } - if (c.kind === 'workflow_block' && 'blockId' in context && 'blockId' in c) { - return ( - c.workflowId === (context as any).workflowId && c.blockId === (context as any).blockId - ) - } - if (c.kind === 'knowledge' && 'knowledgeId' in context && 'knowledgeId' in c) { - return c.knowledgeId === (context as any).knowledgeId - } - if (c.kind === 'templates' && 'templateId' in context && 'templateId' in c) { - return c.templateId === (context as any).templateId - } - if (c.kind === 'logs' && 'executionId' in context && 'executionId' in c) { - return c.executionId === (context as any).executionId - } - if (c.kind === 'docs') { - return true + if (config.useInsertFallback) { + if (!replaceActiveMentionWith(label)) { + insertAtCursor(` @${label} `) } + } else { + replaceActiveMentionWith(label) } - return false - }) - }, - [selectedContexts] - ) - - /** - * Inserts a past chat mention - * - * @param chat - Chat object to mention - */ - const insertPastChatMention = useCallback( - (chat: { id: string; title: string | null }) => { - const label = chat.title || 'New Chat' - const context = { kind: 'past_chat', chatId: chat.id, label } as ChatContext - - // Prevent duplicate insertion - if (isContextAlreadySelected(context)) { - // Clear the partial mention text (e.g., "@Unti") before closing - resetActiveMentionQuery() - setShowMentionMenu(false) - setOpenSubmenuFor(null) - return - } - - replaceActiveMentionWith(label) - onContextAdd(context) - setShowMentionMenu(false) - setOpenSubmenuFor(null) - }, - [ - replaceActiveMentionWith, - onContextAdd, - setShowMentionMenu, - setOpenSubmenuFor, - isContextAlreadySelected, - resetActiveMentionQuery, - ] - ) - - /** - * Inserts a workflow mention - * - * @param wf - Workflow object to mention - */ - const insertWorkflowMention = useCallback( - (wf: { id: string; name: string }) => { - const label = wf.name || 'Untitled Workflow' - const context = { kind: 'workflow', workflowId: wf.id, label } as ChatContext - - // Prevent duplicate insertion - if (isContextAlreadySelected(context)) { - // Clear the partial mention text before closing - resetActiveMentionQuery() - setShowMentionMenu(false) - setOpenSubmenuFor(null) - return - } - - if (!replaceActiveMentionWith(label)) insertAtCursor(` @${label} `) - onContextAdd(context) - setShowMentionMenu(false) - setOpenSubmenuFor(null) - }, - [ - replaceActiveMentionWith, - insertAtCursor, - onContextAdd, - setShowMentionMenu, - setOpenSubmenuFor, - isContextAlreadySelected, - resetActiveMentionQuery, - ] - ) - - /** - * Inserts a knowledge base mention - * - * @param kb - Knowledge base object to mention - */ - const insertKnowledgeMention = useCallback( - (kb: { id: string; name: string }) => { - const label = kb.name || 'Untitled' - const context = { kind: 'knowledge', knowledgeId: kb.id, label } as any - - // Prevent duplicate insertion - if (isContextAlreadySelected(context)) { - // Clear the partial mention text before closing - resetActiveMentionQuery() - setShowMentionMenu(false) - setOpenSubmenuFor(null) - return - } - - replaceActiveMentionWith(label) - onContextAdd(context) - setShowMentionMenu(false) - setOpenSubmenuFor(null) - }, - [ - replaceActiveMentionWith, - onContextAdd, - setShowMentionMenu, - setOpenSubmenuFor, - isContextAlreadySelected, - resetActiveMentionQuery, - ] - ) - - /** - * Inserts a block mention - * - * @param blk - Block object to mention - */ - const insertBlockMention = useCallback( - (blk: { id: string; name: string }) => { - const label = blk.name || blk.id - const context = { kind: 'blocks', blockId: blk.id, label } as any - - // Prevent duplicate insertion - if (isContextAlreadySelected(context)) { - // Clear the partial mention text before closing - resetActiveMentionQuery() - setShowMentionMenu(false) - setOpenSubmenuFor(null) - return + onContextAdd(context) + closeMenus() } - - replaceActiveMentionWith(label) - onContextAdd(context) - setShowMentionMenu(false) - setOpenSubmenuFor(null) }, [ - replaceActiveMentionWith, - onContextAdd, - setShowMentionMenu, - setOpenSubmenuFor, - isContextAlreadySelected, - resetActiveMentionQuery, - ] - ) - - /** - * Inserts a workflow block mention - * - * @param blk - Workflow block object to mention - */ - const insertWorkflowBlockMention = useCallback( - (blk: { id: string; name: string }) => { - const label = blk.name - const context = { - kind: 'workflow_block', - workflowId: workflowId as string, - blockId: blk.id, - label, - } as any - - // Prevent duplicate insertion - if (isContextAlreadySelected(context)) { - // Clear the partial mention text before closing - resetActiveMentionQuery() - setShowMentionMenu(false) - setOpenSubmenuFor(null) - return - } - - if (!replaceActiveMentionWith(label)) insertAtCursor(` @${label} `) - onContextAdd(context) - setShowMentionMenu(false) - setOpenSubmenuFor(null) - }, - [ - replaceActiveMentionWith, - insertAtCursor, workflowId, - onContextAdd, - setShowMentionMenu, - setOpenSubmenuFor, - isContextAlreadySelected, - resetActiveMentionQuery, - ] - ) - - /** - * Inserts a template mention - * - * @param tpl - Template object to mention - */ - const insertTemplateMention = useCallback( - (tpl: { id: string; name: string }) => { - const label = tpl.name || 'Untitled Template' - const context = { kind: 'templates', templateId: tpl.id, label } as any - - // Prevent duplicate insertion - if (isContextAlreadySelected(context)) { - // Clear the partial mention text before closing - resetActiveMentionQuery() - setShowMentionMenu(false) - setOpenSubmenuFor(null) - return - } - - replaceActiveMentionWith(label) - onContextAdd(context) - setShowMentionMenu(false) - setOpenSubmenuFor(null) - }, - [ - replaceActiveMentionWith, - onContextAdd, - setShowMentionMenu, - setOpenSubmenuFor, - isContextAlreadySelected, - resetActiveMentionQuery, - ] - ) - - /** - * Inserts a log mention - * - * @param log - Log object to mention - */ - const insertLogMention = useCallback( - (log: { id: string; executionId?: string; workflowName: string }) => { - const label = log.workflowName - const context = { kind: 'logs' as const, executionId: log.executionId, label } - - // Prevent duplicate insertion - if (isContextAlreadySelected(context)) { - // Clear the partial mention text before closing - resetActiveMentionQuery() - setShowMentionMenu(false) - setOpenSubmenuFor(null) - return - } - - replaceActiveMentionWith(label) - onContextAdd(context) - setShowMentionMenu(false) - setOpenSubmenuFor(null) - }, - [ + selectedContexts, replaceActiveMentionWith, + insertAtCursor, onContextAdd, - setShowMentionMenu, - setOpenSubmenuFor, - isContextAlreadySelected, resetActiveMentionQuery, + closeMenus, ] ) /** - * Inserts a docs mention + * Special handler for Docs (no item parameter, uses DOCS_CONFIG) */ const insertDocsMention = useCallback(() => { - const label = 'Docs' - const context = { kind: 'docs', label } as any + const label = DOCS_CONFIG.getLabel() + const context = DOCS_CONFIG.buildContext() // Prevent duplicate insertion - if (isContextAlreadySelected(context)) { - // Clear the partial mention text before closing + if (isContextAlreadySelected(context, selectedContexts)) { resetActiveMentionQuery() - setShowMentionMenu(false) - setOpenSubmenuFor(null) + closeMenus() return } - if (!replaceActiveMentionWith(label)) insertAtCursor(` @${label} `) + // Docs uses fallback insertion + if (!replaceActiveMentionWith(label)) { + insertAtCursor(` @${label} `) + } + onContextAdd(context) - setShowMentionMenu(false) - setOpenSubmenuFor(null) + closeMenus() }, [ + selectedContexts, replaceActiveMentionWith, insertAtCursor, onContextAdd, - setShowMentionMenu, - setOpenSubmenuFor, - isContextAlreadySelected, resetActiveMentionQuery, + closeMenus, ]) - return { - insertPastChatMention, - insertWorkflowMention, - insertKnowledgeMention, - insertBlockMention, - insertWorkflowBlockMention, - insertTemplateMention, - insertLogMention, - insertDocsMention, - } + const handlers = useMemo( + () => ({ + insertPastChatMention: createInsertHandler(FOLDER_CONFIGS.chats), + insertWorkflowMention: createInsertHandler(FOLDER_CONFIGS.workflows), + insertKnowledgeMention: createInsertHandler(FOLDER_CONFIGS.knowledge), + insertBlockMention: createInsertHandler(FOLDER_CONFIGS.blocks), + insertWorkflowBlockMention: createInsertHandler(FOLDER_CONFIGS['workflow-blocks']), + insertTemplateMention: createInsertHandler(FOLDER_CONFIGS.templates), + insertLogMention: createInsertHandler(FOLDER_CONFIGS.logs), + insertDocsMention, + }), + [createInsertHandler, insertDocsMention] + ) + + return handlers } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard.ts index b36e73a286..27e4385410 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard.ts @@ -1,56 +1,19 @@ -import { type KeyboardEvent, useCallback } from 'react' -import type { useMentionData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data' -import type { useMentionMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu' -import { MENTION_OPTIONS } from '../constants' - -/** - * Chat item for mention insertion - */ -interface ChatItem { - id: string - title: string | null -} - -/** - * Workflow item for mention insertion - */ -interface WorkflowItem { - id: string - name: string -} - -/** - * Knowledge base item for mention insertion - */ -interface KnowledgeItem { - id: string - name: string -} - -/** - * Block item for mention insertion - */ -interface BlockItem { - id: string - name: string -} - -/** - * Template item for mention insertion - */ -interface TemplateItem { - id: string - name: string -} - -/** - * Log item for mention insertion - */ -interface LogItem { - id: string - executionId?: string - workflowName: string -} +import { type KeyboardEvent, useCallback, useMemo } from 'react' +import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components' +import { + FOLDER_CONFIGS, + FOLDER_ORDER, + type MentionFolderId, + ROOT_MENU_ITEM_COUNT, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' +import type { + useMentionData, + useMentionMenu, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks' +import { + getFolderData as getFolderDataUtil, + getFolderEnsureLoaded as getFolderEnsureLoadedUtil, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' interface UseMentionKeyboardProps { /** Mention menu hook instance */ @@ -59,37 +22,34 @@ interface UseMentionKeyboardProps { mentionData: ReturnType /** Callback to insert specific mention types */ insertHandlers: { - insertPastChatMention: (chat: ChatItem) => void - insertWorkflowMention: (wf: WorkflowItem) => void - insertKnowledgeMention: (kb: KnowledgeItem) => void - insertBlockMention: (blk: BlockItem) => void - insertWorkflowBlockMention: (blk: BlockItem) => void - insertTemplateMention: (tpl: TemplateItem) => void - insertLogMention: (log: LogItem) => void + insertPastChatMention: (chat: any) => void + insertWorkflowMention: (wf: any) => void + insertKnowledgeMention: (kb: any) => void + insertBlockMention: (blk: any) => void + insertWorkflowBlockMention: (blk: any) => void + insertTemplateMention: (tpl: any) => void + insertLogMention: (log: any) => void insertDocsMention: () => void } + /** Folder navigation state exposed from MentionMenu via callback */ + mentionFolderNav: MentionFolderNav | null } /** * Custom hook to handle keyboard navigation in the mention menu. - * Manages Arrow Up/Down/Left/Right and Enter key navigation through menus and submenus. - * - * @param props - Configuration object - * @returns Keyboard handler for mention menu */ export function useMentionKeyboard({ mentionMenu, mentionData, insertHandlers, + mentionFolderNav, }: UseMentionKeyboardProps) { const { showMentionMenu, - openSubmenuFor, mentionActiveIndex, submenuActiveIndex, setMentionActiveIndex, setSubmenuActiveIndex, - setOpenSubmenuFor, setSubmenuQueryStart, getCaretPos, getActiveMentionQueryAtPosition, @@ -98,65 +58,101 @@ export function useMentionKeyboard({ scrollActiveItemIntoView, } = mentionMenu - const { - pastChats, - workflows, - knowledgeBases, - blocksList, - workflowBlocks, - templatesList, - logsList, - ensurePastChatsLoaded, - ensureWorkflowsLoaded, - ensureKnowledgeLoaded, - ensureBlocksLoaded, - ensureWorkflowBlocksLoaded, - ensureTemplatesLoaded, - ensureLogsLoaded, - } = mentionData + const currentFolder = mentionFolderNav?.currentFolder ?? null + const isInFolder = mentionFolderNav?.isInFolder ?? false - const { - insertPastChatMention, - insertWorkflowMention, - insertKnowledgeMention, - insertBlockMention, - insertWorkflowBlockMention, - insertTemplateMention, - insertLogMention, - insertDocsMention, - } = insertHandlers + /** + * Map of folder IDs to insert handlers + */ + const insertHandlerMap = useMemo( + (): Record void> => ({ + chats: insertHandlers.insertPastChatMention, + workflows: insertHandlers.insertWorkflowMention, + knowledge: insertHandlers.insertKnowledgeMention, + blocks: insertHandlers.insertBlockMention, + 'workflow-blocks': insertHandlers.insertWorkflowBlockMention, + templates: insertHandlers.insertTemplateMention, + logs: insertHandlers.insertLogMention, + }), + [insertHandlers] + ) + + /** + * Get data array for a folder from mentionData + */ + const getFolderData = useCallback( + (folderId: MentionFolderId) => getFolderDataUtil(mentionData, folderId), + [mentionData] + ) + + /** + * Filter items for a folder based on query using config's filterFn + */ + const filterFolderItems = useCallback( + (folderId: MentionFolderId, query: string): any[] => { + const config = FOLDER_CONFIGS[folderId] + const items = getFolderData(folderId) + if (!query) return items + const q = query.toLowerCase() + return items.filter((item) => config.filterFn(item, q)) + }, + [getFolderData] + ) + + /** + * Ensure data is loaded for a folder + */ + const ensureFolderLoaded = useCallback( + (folderId: MentionFolderId): void => { + const ensureFn = getFolderEnsureLoadedUtil(mentionData, folderId) + if (ensureFn) void ensureFn() + }, + [mentionData] + ) /** * Build aggregated list matching the portal's ordering */ const buildAggregatedList = useCallback( - (query: string) => { + (query: string): Array<{ type: MentionFolderId | 'docs'; value: any }> => { const q = query.toLowerCase() - return [ - ...pastChats - .filter((c) => (c.title || 'New Chat').toLowerCase().includes(q)) - .map((c) => ({ type: 'Chats' as const, value: c })), - ...workflows - .filter((w) => (w.name || 'Untitled Workflow').toLowerCase().includes(q)) - .map((w) => ({ type: 'Workflows' as const, value: w })), - ...knowledgeBases - .filter((k) => (k.name || 'Untitled').toLowerCase().includes(q)) - .map((k) => ({ type: 'Knowledge' as const, value: k })), - ...blocksList - .filter((b) => (b.name || b.id).toLowerCase().includes(q)) - .map((b) => ({ type: 'Blocks' as const, value: b })), - ...workflowBlocks - .filter((b) => (b.name || b.id).toLowerCase().includes(q)) - .map((b) => ({ type: 'Workflow Blocks' as const, value: b })), - ...templatesList - .filter((t) => (t.name || 'Untitled Template').toLowerCase().includes(q)) - .map((t) => ({ type: 'Templates' as const, value: t })), - ...logsList - .filter((l) => (l.workflowName || 'Untitled Workflow').toLowerCase().includes(q)) - .map((l) => ({ type: 'Logs' as const, value: l })), - ] + const result: Array<{ type: MentionFolderId | 'docs'; value: any }> = [] + + for (const folderId of FOLDER_ORDER) { + const filtered = filterFolderItems(folderId, q) + filtered.forEach((item) => { + result.push({ type: folderId, value: item }) + }) + } + + if ('docs'.includes(q)) { + result.push({ type: 'docs', value: null }) + } + + return result }, - [pastChats, workflows, knowledgeBases, blocksList, workflowBlocks, templatesList, logsList] + [filterFolderItems] + ) + + /** + * Generic navigation helper for navigating through items + */ + const navigateItems = useCallback( + ( + direction: 'up' | 'down', + itemCount: number, + setIndex: (fn: (prev: number) => number) => void + ) => { + setIndex((prev) => { + const last = Math.max(0, itemCount - 1) + if (itemCount === 0) return 0 + const next = + direction === 'down' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1 + requestAnimationFrame(() => scrollActiveItemIntoView(next)) + return next + }) + }, + [scrollActiveItemIntoView] ) /** @@ -169,143 +165,36 @@ export function useMentionKeyboard({ e.preventDefault() const caretPos = getCaretPos() const active = getActiveMentionQueryAtPosition(caretPos) - const mainQ = (!openSubmenuFor ? active?.query || '' : '').toLowerCase() + const mainQ = (!isInFolder ? active?.query || '' : '').toLowerCase() + const direction = e.key === 'ArrowDown' ? 'down' : 'up' - // When there's a query, we show aggregated filtered view (no folders) const showAggregatedView = mainQ.length > 0 - const aggregatedList = showAggregatedView ? buildAggregatedList(mainQ) : [] - - // When showing aggregated filtered view, navigate through the aggregated list - if (showAggregatedView && !openSubmenuFor) { - setSubmenuActiveIndex((prev) => { - const last = Math.max(0, aggregatedList.length - 1) - if (aggregatedList.length === 0) return 0 - const next = - e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1 - requestAnimationFrame(() => scrollActiveItemIntoView(next)) - return next - }) + if (showAggregatedView && !isInFolder) { + const aggregatedList = buildAggregatedList(mainQ) + navigateItems(direction, aggregatedList.length, setSubmenuActiveIndex) return true } - // Handle submenu navigation - if (openSubmenuFor === 'Chats') { - const q = getSubmenuQuery().toLowerCase() - const filtered = pastChats.filter((c) => (c.title || 'New Chat').toLowerCase().includes(q)) - setSubmenuActiveIndex((prev) => { - const last = Math.max(0, filtered.length - 1) - if (filtered.length === 0) return 0 - const next = - e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1 - requestAnimationFrame(() => scrollActiveItemIntoView(next)) - return next - }) - } else if (openSubmenuFor === 'Workflows') { - const q = getSubmenuQuery().toLowerCase() - const filtered = workflows.filter((w) => - (w.name || 'Untitled Workflow').toLowerCase().includes(q) - ) - setSubmenuActiveIndex((prev) => { - const last = Math.max(0, filtered.length - 1) - if (filtered.length === 0) return 0 - const next = - e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1 - requestAnimationFrame(() => scrollActiveItemIntoView(next)) - return next - }) - } else if (openSubmenuFor === 'Knowledge') { - const q = getSubmenuQuery().toLowerCase() - const filtered = knowledgeBases.filter((k) => - (k.name || 'Untitled').toLowerCase().includes(q) - ) - setSubmenuActiveIndex((prev) => { - const last = Math.max(0, filtered.length - 1) - if (filtered.length === 0) return 0 - const next = - e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1 - requestAnimationFrame(() => scrollActiveItemIntoView(next)) - return next - }) - } else if (openSubmenuFor === 'Blocks') { + if (currentFolder && FOLDER_CONFIGS[currentFolder as MentionFolderId]) { const q = getSubmenuQuery().toLowerCase() - const filtered = blocksList.filter((b) => (b.name || b.id).toLowerCase().includes(q)) - setSubmenuActiveIndex((prev) => { - const last = Math.max(0, filtered.length - 1) - if (filtered.length === 0) return 0 - const next = - e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1 - requestAnimationFrame(() => scrollActiveItemIntoView(next)) - return next - }) - } else if (openSubmenuFor === 'Workflow Blocks') { - const q = getSubmenuQuery().toLowerCase() - const filtered = workflowBlocks.filter((b) => (b.name || b.id).toLowerCase().includes(q)) - setSubmenuActiveIndex((prev) => { - const last = Math.max(0, filtered.length - 1) - if (filtered.length === 0) return 0 - const next = - e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1 - requestAnimationFrame(() => scrollActiveItemIntoView(next)) - return next - }) - } else if (openSubmenuFor === 'Templates') { - const q = getSubmenuQuery().toLowerCase() - const filtered = templatesList.filter((t) => - (t.name || 'Untitled Template').toLowerCase().includes(q) - ) - setSubmenuActiveIndex((prev) => { - const last = Math.max(0, filtered.length - 1) - if (filtered.length === 0) return 0 - const next = - e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1 - requestAnimationFrame(() => scrollActiveItemIntoView(next)) - return next - }) - } else if (openSubmenuFor === 'Logs') { - const q = getSubmenuQuery().toLowerCase() - const filtered = logsList.filter((l) => - [l.workflowName, l.trigger || ''].join(' ').toLowerCase().includes(q) - ) - setSubmenuActiveIndex((prev) => { - const last = Math.max(0, filtered.length - 1) - if (filtered.length === 0) return 0 - const next = - e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1 - requestAnimationFrame(() => scrollActiveItemIntoView(next)) - return next - }) - } else { - // Navigate through folder options when no query - const filteredMain = MENTION_OPTIONS.filter((o) => o.toLowerCase().includes(mainQ)) - setMentionActiveIndex((prev) => { - const last = Math.max(0, filteredMain.length - 1) - if (filteredMain.length === 0) return 0 - const next = - e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1 - requestAnimationFrame(() => scrollActiveItemIntoView(next)) - return next - }) + const filtered = filterFolderItems(currentFolder as MentionFolderId, q) + navigateItems(direction, filtered.length, setSubmenuActiveIndex) + return true } + navigateItems(direction, ROOT_MENU_ITEM_COUNT, setMentionActiveIndex) return true }, [ showMentionMenu, - openSubmenuFor, - mentionActiveIndex, - submenuActiveIndex, + isInFolder, + currentFolder, buildAggregatedList, - pastChats, - workflows, - knowledgeBases, - blocksList, - workflowBlocks, - templatesList, - logsList, + filterFolderItems, + navigateItems, getCaretPos, getActiveMentionQueryAtPosition, getSubmenuQuery, - scrollActiveItemIntoView, setMentionActiveIndex, setSubmenuActiveIndex, ] @@ -316,65 +205,30 @@ export function useMentionKeyboard({ */ const handleArrowRight = useCallback( (e: KeyboardEvent) => { - if (!showMentionMenu || e.key !== 'ArrowRight') return false + if (!showMentionMenu || e.key !== 'ArrowRight' || !mentionFolderNav) return false const caretPos = getCaretPos() const active = getActiveMentionQueryAtPosition(caretPos) const mainQ = (active?.query || '').toLowerCase() - const showAggregatedView = mainQ.length > 0 - // Don't handle arrow right in aggregated view (user is filtering, not navigating folders) - if (showAggregatedView) return false + if (mainQ.length > 0) return false e.preventDefault() - const filteredMain = MENTION_OPTIONS.filter((o) => o.toLowerCase().includes(mainQ)) - const selected = filteredMain[mentionActiveIndex] - if (selected === 'Chats') { - resetActiveMentionQuery() - setOpenSubmenuFor('Chats') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensurePastChatsLoaded() - } else if (selected === 'Workflows') { - resetActiveMentionQuery() - setOpenSubmenuFor('Workflows') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureWorkflowsLoaded() - } else if (selected === 'Knowledge') { - resetActiveMentionQuery() - setOpenSubmenuFor('Knowledge') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureKnowledgeLoaded() - } else if (selected === 'Blocks') { - resetActiveMentionQuery() - setOpenSubmenuFor('Blocks') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureBlocksLoaded() - } else if (selected === 'Workflow Blocks') { + const isDocsSelected = mentionActiveIndex === FOLDER_ORDER.length + if (isDocsSelected) { resetActiveMentionQuery() - setOpenSubmenuFor('Workflow Blocks') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureWorkflowBlocksLoaded() - } else if (selected === 'Docs') { - resetActiveMentionQuery() - insertDocsMention() - } else if (selected === 'Templates') { - resetActiveMentionQuery() - setOpenSubmenuFor('Templates') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureTemplatesLoaded() - } else if (selected === 'Logs') { + insertHandlers.insertDocsMention() + return true + } + + const selectedFolderId = FOLDER_ORDER[mentionActiveIndex] + if (selectedFolderId) { + const config = FOLDER_CONFIGS[selectedFolderId] resetActiveMentionQuery() - setOpenSubmenuFor('Logs') - setSubmenuActiveIndex(0) + mentionFolderNav.openFolder(selectedFolderId, config.title) setSubmenuQueryStart(getCaretPos()) - void ensureLogsLoaded() + ensureFolderLoaded(selectedFolderId) } return true @@ -382,21 +236,13 @@ export function useMentionKeyboard({ [ showMentionMenu, mentionActiveIndex, - openSubmenuFor, + mentionFolderNav, getCaretPos, getActiveMentionQueryAtPosition, resetActiveMentionQuery, - setOpenSubmenuFor, - setSubmenuActiveIndex, setSubmenuQueryStart, - ensurePastChatsLoaded, - ensureWorkflowsLoaded, - ensureKnowledgeLoaded, - ensureBlocksLoaded, - ensureWorkflowBlocksLoaded, - ensureTemplatesLoaded, - ensureLogsLoaded, - insertDocsMention, + ensureFolderLoaded, + insertHandlers, ] ) @@ -407,16 +253,16 @@ export function useMentionKeyboard({ (e: KeyboardEvent) => { if (!showMentionMenu || e.key !== 'ArrowLeft') return false - if (openSubmenuFor) { + if (isInFolder && mentionFolderNav) { e.preventDefault() - setOpenSubmenuFor(null) + mentionFolderNav.closeFolder() setSubmenuQueryStart(null) return true } return false }, - [showMentionMenu, openSubmenuFor, setOpenSubmenuFor, setSubmenuQueryStart] + [showMentionMenu, isInFolder, mentionFolderNav, setSubmenuQueryStart] ) /** @@ -429,179 +275,74 @@ export function useMentionKeyboard({ e.preventDefault() const caretPos = getCaretPos() const active = getActiveMentionQueryAtPosition(caretPos) - const mainQ = (active?.query || '').toLowerCase() + const mainQ = (!isInFolder ? active?.query || '' : '').toLowerCase() const showAggregatedView = mainQ.length > 0 - const filteredMain = MENTION_OPTIONS.filter((o) => o.toLowerCase().includes(mainQ)) - const selected = filteredMain[mentionActiveIndex] - // Handle selection in aggregated filtered view - if (showAggregatedView && !openSubmenuFor) { + if (showAggregatedView && !isInFolder) { const aggregated = buildAggregatedList(mainQ) const idx = Math.max(0, Math.min(submenuActiveIndex, aggregated.length - 1)) const chosen = aggregated[idx] if (chosen) { - if (chosen.type === 'Chats') insertPastChatMention(chosen.value as ChatItem) - else if (chosen.type === 'Workflows') insertWorkflowMention(chosen.value as WorkflowItem) - else if (chosen.type === 'Knowledge') - insertKnowledgeMention(chosen.value as KnowledgeItem) - else if (chosen.type === 'Workflow Blocks') - insertWorkflowBlockMention(chosen.value as BlockItem) - else if (chosen.type === 'Blocks') insertBlockMention(chosen.value as BlockItem) - else if (chosen.type === 'Templates') insertTemplateMention(chosen.value as TemplateItem) - else if (chosen.type === 'Logs') insertLogMention(chosen.value as LogItem) + if (chosen.type === 'docs') { + insertHandlers.insertDocsMention() + } else { + const handler = insertHandlerMap[chosen.type] + handler(chosen.value) + } } return true } - // Handle folder navigation when no query - if (!openSubmenuFor && selected === 'Chats') { - resetActiveMentionQuery() - setOpenSubmenuFor('Chats') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensurePastChatsLoaded() - } else if (openSubmenuFor === 'Chats') { - const q = getSubmenuQuery().toLowerCase() - const filtered = pastChats.filter((c) => (c.title || 'New Chat').toLowerCase().includes(q)) - if (filtered.length > 0) { - const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))] - insertPastChatMention(chosen) - setSubmenuQueryStart(null) - } - } else if (!openSubmenuFor && selected === 'Workflows') { - resetActiveMentionQuery() - setOpenSubmenuFor('Workflows') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureWorkflowsLoaded() - } else if (openSubmenuFor === 'Workflows') { - const q = getSubmenuQuery().toLowerCase() - const filtered = workflows.filter((w) => - (w.name || 'Untitled Workflow').toLowerCase().includes(q) - ) - if (filtered.length > 0) { - const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))] - insertWorkflowMention(chosen) - setSubmenuQueryStart(null) - } - } else if (!openSubmenuFor && selected === 'Knowledge') { - resetActiveMentionQuery() - setOpenSubmenuFor('Knowledge') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureKnowledgeLoaded() - } else if (openSubmenuFor === 'Knowledge') { - const q = getSubmenuQuery().toLowerCase() - const filtered = knowledgeBases.filter((k) => - (k.name || 'Untitled').toLowerCase().includes(q) - ) - if (filtered.length > 0) { - const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))] - insertKnowledgeMention(chosen) - setSubmenuQueryStart(null) - } - } else if (!openSubmenuFor && selected === 'Blocks') { - resetActiveMentionQuery() - setOpenSubmenuFor('Blocks') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureBlocksLoaded() - } else if (openSubmenuFor === 'Blocks') { - const q = getSubmenuQuery().toLowerCase() - const filtered = blocksList.filter((b) => (b.name || b.id).toLowerCase().includes(q)) - if (filtered.length > 0) { - const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))] - insertBlockMention(chosen) - setSubmenuQueryStart(null) - } - } else if (!openSubmenuFor && selected === 'Workflow Blocks') { - resetActiveMentionQuery() - setOpenSubmenuFor('Workflow Blocks') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureWorkflowBlocksLoaded() - } else if (openSubmenuFor === 'Workflow Blocks') { + if (isInFolder && currentFolder && FOLDER_CONFIGS[currentFolder as MentionFolderId]) { + const folderId = currentFolder as MentionFolderId const q = getSubmenuQuery().toLowerCase() - const filtered = workflowBlocks.filter((b) => (b.name || b.id).toLowerCase().includes(q)) + const filtered = filterFolderItems(folderId, q) if (filtered.length > 0) { const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))] - insertWorkflowBlockMention(chosen) + const handler = insertHandlerMap[folderId] + handler(chosen) setSubmenuQueryStart(null) } - } else if (!openSubmenuFor && selected === 'Docs') { - resetActiveMentionQuery() - insertDocsMention() - } else if (!openSubmenuFor && selected === 'Templates') { + return true + } + + const isDocsSelected = mentionActiveIndex === FOLDER_ORDER.length + if (isDocsSelected) { resetActiveMentionQuery() - setOpenSubmenuFor('Templates') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureTemplatesLoaded() - } else if (!openSubmenuFor && selected === 'Logs') { + insertHandlers.insertDocsMention() + return true + } + + const selectedFolderId = FOLDER_ORDER[mentionActiveIndex] + if (selectedFolderId && mentionFolderNav) { + const config = FOLDER_CONFIGS[selectedFolderId] resetActiveMentionQuery() - setOpenSubmenuFor('Logs') + mentionFolderNav.openFolder(selectedFolderId, config.title) setSubmenuActiveIndex(0) setSubmenuQueryStart(getCaretPos()) - void ensureLogsLoaded() - } else if (openSubmenuFor === 'Templates') { - const q = getSubmenuQuery().toLowerCase() - const filtered = templatesList.filter((t) => - (t.name || 'Untitled Template').toLowerCase().includes(q) - ) - if (filtered.length > 0) { - const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))] - insertTemplateMention(chosen) - setSubmenuQueryStart(null) - } - } else if (openSubmenuFor === 'Logs') { - const q = getSubmenuQuery().toLowerCase() - const filtered = logsList.filter((l) => - [l.workflowName, l.trigger || ''].join(' ').toLowerCase().includes(q) - ) - if (filtered.length > 0) { - const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))] - insertLogMention(chosen) - setSubmenuQueryStart(null) - } + ensureFolderLoaded(selectedFolderId) } return true }, [ showMentionMenu, - openSubmenuFor, + isInFolder, + currentFolder, mentionActiveIndex, submenuActiveIndex, + mentionFolderNav, buildAggregatedList, - pastChats, - workflows, - knowledgeBases, - blocksList, - workflowBlocks, - templatesList, - logsList, + filterFolderItems, + insertHandlerMap, getCaretPos, getActiveMentionQueryAtPosition, getSubmenuQuery, resetActiveMentionQuery, - setOpenSubmenuFor, setSubmenuActiveIndex, setSubmenuQueryStart, - ensurePastChatsLoaded, - ensureWorkflowsLoaded, - ensureKnowledgeLoaded, - ensureBlocksLoaded, - ensureWorkflowBlocksLoaded, - ensureTemplatesLoaded, - ensureLogsLoaded, - insertPastChatMention, - insertWorkflowMention, - insertKnowledgeMention, - insertBlockMention, - insertWorkflowBlockMention, - insertTemplateMention, - insertLogMention, - insertDocsMention, + ensureFolderLoaded, + insertHandlers, ] ) 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 8a07146e05..4859409219 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 @@ -1,9 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { createLogger } from '@sim/logger' +import { SCROLL_TOLERANCE } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' import type { ChatContext } from '@/stores/panel' -import { SCROLL_TOLERANCE } from '../constants' - -const logger = createLogger('useMentionMenu') interface UseMentionMenuProps { /** Current message text */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-textarea-auto-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-textarea-auto-resize.ts index 7fabea1da9..82ee7107ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-textarea-auto-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-textarea-auto-resize.ts @@ -49,7 +49,6 @@ export function useTextareaAutoResize({ const styles = window.getComputedStyle(textarea) - // Copy all text rendering properties exactly (but NOT color - overlay needs visible text) overlay.style.font = styles.font overlay.style.fontSize = styles.fontSize overlay.style.fontFamily = styles.fontFamily @@ -66,7 +65,6 @@ export function useTextareaAutoResize({ overlay.style.textTransform = styles.textTransform overlay.style.textIndent = styles.textIndent - // Copy box model properties exactly to ensure identical text flow overlay.style.padding = styles.padding overlay.style.paddingTop = styles.paddingTop overlay.style.paddingRight = styles.paddingRight @@ -80,7 +78,6 @@ export function useTextareaAutoResize({ overlay.style.border = styles.border overlay.style.borderWidth = styles.borderWidth - // Copy text wrapping and breaking properties overlay.style.whiteSpace = styles.whiteSpace overlay.style.wordBreak = styles.wordBreak overlay.style.wordWrap = styles.wordWrap @@ -91,20 +88,17 @@ export function useTextareaAutoResize({ overlay.style.direction = styles.direction overlay.style.hyphens = (styles as any).hyphens ?? '' - // Critical: Match dimensions exactly const textareaWidth = textarea.clientWidth const textareaHeight = textarea.clientHeight overlay.style.width = `${textareaWidth}px` overlay.style.height = `${textareaHeight}px` - // Match max-height behavior const computedMaxHeight = styles.maxHeight if (computedMaxHeight && computedMaxHeight !== 'none') { overlay.style.maxHeight = computedMaxHeight } - // Ensure scroll positions are perfectly synced overlay.scrollTop = textarea.scrollTop overlay.scrollLeft = textarea.scrollLeft }) @@ -119,25 +113,20 @@ export function useTextareaAutoResize({ const overlay = overlayRef.current if (!textarea || !overlay) return - // Store current cursor position to determine if user is typing at the end const cursorPos = textarea.selectionStart ?? 0 const isAtEnd = cursorPos === message.length const wasScrolledToBottom = textarea.scrollHeight - textarea.scrollTop - textarea.clientHeight < 5 - // Reset height to auto to get proper scrollHeight textarea.style.height = 'auto' overlay.style.height = 'auto' - // Force a reflow to ensure accurate scrollHeight void textarea.offsetHeight void overlay.offsetHeight - // Get the scroll height (this includes all content, including trailing newlines) const scrollHeight = textarea.scrollHeight const nextHeight = Math.min(scrollHeight, MAX_TEXTAREA_HEIGHT) - // Apply height to BOTH elements simultaneously const heightString = `${nextHeight}px` const overflowString = scrollHeight > MAX_TEXTAREA_HEIGHT ? 'auto' : 'hidden' @@ -146,22 +135,18 @@ export function useTextareaAutoResize({ overlay.style.height = heightString overlay.style.overflowY = overflowString - // Force another reflow after height change void textarea.offsetHeight void overlay.offsetHeight - // Maintain scroll behavior: if user was at bottom or typing at end, keep them at bottom if ((isAtEnd || wasScrolledToBottom) && scrollHeight > nextHeight) { const scrollValue = scrollHeight textarea.scrollTop = scrollValue overlay.scrollTop = scrollValue } else { - // Otherwise, sync scroll positions overlay.scrollTop = textarea.scrollTop overlay.scrollLeft = textarea.scrollLeft } - // Sync all other styles after height change syncOverlayStyles.current() }, [message, selectedContexts, textareaRef]) @@ -192,19 +177,15 @@ export function useTextareaAutoResize({ const overlay = overlayRef.current if (!textarea || !overlay || !containerRef || typeof window === 'undefined') return - // Initial sync syncOverlayStyles.current() - // Observe the CONTAINER - when pills wrap, container height changes if (typeof ResizeObserver !== 'undefined' && !containerResizeObserverRef.current) { containerResizeObserverRef.current = new ResizeObserver(() => { - // Container size changed (pills wrapped) - sync immediately syncOverlayStyles.current() }) containerResizeObserverRef.current.observe(containerRef) } - // ALSO observe the textarea for its own size changes if (typeof ResizeObserver !== 'undefined' && !textareaResizeObserverRef.current) { textareaResizeObserverRef.current = new ResizeObserver(() => { syncOverlayStyles.current() @@ -212,7 +193,6 @@ export function useTextareaAutoResize({ textareaResizeObserverRef.current.observe(textarea) } - // Setup MutationObserver to detect style changes const mutationObserver = new MutationObserver(() => { syncOverlayStyles.current() }) @@ -221,11 +201,9 @@ export function useTextareaAutoResize({ attributeFilter: ['style', 'class'], }) - // Listen to window resize events (for browser window resizing) const handleResize = () => syncOverlayStyles.current() window.addEventListener('resize', handleResize) - // Cleanup return () => { mutationObserver.disconnect() window.removeEventListener('resize', handleResize) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx index 665266bbbd..a5e19fd130 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx @@ -18,12 +18,21 @@ import { cn } from '@/lib/core/utils/cn' import { AttachedFilesDisplay, ContextPills, + type MentionFolderNav, MentionMenu, ModelSelector, ModeSelector, + type SlashFolderNav, SlashMenu, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components' -import { NEAR_TOP_THRESHOLD } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' +import { + ALL_COMMAND_IDS, + getCommandDisplayLabel, + getNextIndex, + NEAR_TOP_THRESHOLD, + TOP_LEVEL_COMMANDS, + WEB_COMMANDS, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' import { useContextManagement, useFileAttachments, @@ -40,24 +49,6 @@ import { useCopilotStore } from '@/stores/panel' const logger = createLogger('CopilotUserInput') -const TOP_LEVEL_COMMANDS = ['fast', 'research', 'superagent'] as const -const WEB_COMMANDS = ['search', 'read', 'scrape', 'crawl'] as const -const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS] - -const COMMAND_DISPLAY_LABELS: Record = { - superagent: 'Actions', -} - -/** - * Calculates the next index for circular navigation (wraps around at bounds) - */ -function getNextIndex(current: number, direction: 'up' | 'down', maxIndex: number): number { - if (direction === 'down') { - return current >= maxIndex ? 0 : current + 1 - } - return current <= 0 ? maxIndex : current - 1 -} - interface UserInputProps { onSubmit: ( message: string, @@ -144,6 +135,8 @@ const UserInput = forwardRef( const [containerRef, setContainerRef] = useState(null) const [inputContainerRef, setInputContainerRef] = useState(null) const [showSlashMenu, setShowSlashMenu] = useState(false) + const [slashFolderNav, setSlashFolderNav] = useState(null) + const [mentionFolderNav, setMentionFolderNav] = useState(null) const message = controlledValue !== undefined ? controlledValue : internalMessage const setMessage = @@ -198,12 +191,14 @@ const UserInput = forwardRef( workflowId: workflowId || null, selectedContexts: contextManagement.selectedContexts, onContextAdd: contextManagement.addContext, + mentionFolderNav, }) const mentionKeyboard = useMentionKeyboard({ mentionMenu, mentionData, insertHandlers, + mentionFolderNav, }) useImperativeHandle( @@ -222,13 +217,6 @@ const UserInput = forwardRef( [mentionMenu.textareaRef] ) - useEffect(() => { - if (workflowId) { - void mentionData.ensureWorkflowsLoaded() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workflowId]) - useEffect(() => { const checkPosition = () => { if (containerRef) { @@ -264,7 +252,7 @@ const UserInput = forwardRef( }, [mentionMenu.showMentionMenu, containerRef]) useEffect(() => { - if (!mentionMenu.showMentionMenu || mentionMenu.openSubmenuFor) { + if (!mentionMenu.showMentionMenu || mentionFolderNav?.isInFolder) { return } @@ -275,8 +263,7 @@ const UserInput = forwardRef( if (q && q.length > 0) { void mentionData.ensurePastChatsLoaded() - void mentionData.ensureWorkflowsLoaded() - void mentionData.ensureWorkflowBlocksLoaded() + // workflows and workflow-blocks auto-load from stores void mentionData.ensureKnowledgeLoaded() void mentionData.ensureBlocksLoaded() void mentionData.ensureTemplatesLoaded() @@ -286,15 +273,15 @@ const UserInput = forwardRef( requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(0)) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mentionMenu.showMentionMenu, mentionMenu.openSubmenuFor, message]) + }, [mentionMenu.showMentionMenu, mentionFolderNav?.isInFolder, message]) useEffect(() => { - if (mentionMenu.openSubmenuFor) { + if (mentionFolderNav?.isInFolder) { mentionMenu.setSubmenuActiveIndex(0) requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(0)) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mentionMenu.openSubmenuFor]) + }, [mentionFolderNav?.isInFolder]) const handleSubmit = useCallback( async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => { @@ -372,8 +359,7 @@ const UserInput = forwardRef( const handleSlashCommandSelect = useCallback( (command: string) => { - const displayLabel = - COMMAND_DISPLAY_LABELS[command] || command.charAt(0).toUpperCase() + command.slice(1) + const displayLabel = getCommandDisplayLabel(command) mentionMenu.replaceActiveSlashWith(displayLabel) contextManagement.addContext({ kind: 'slash_command', @@ -391,9 +377,11 @@ const UserInput = forwardRef( (e: KeyboardEvent) => { if (e.key === 'Escape' && (mentionMenu.showMentionMenu || showSlashMenu)) { e.preventDefault() - if (mentionMenu.openSubmenuFor) { - mentionMenu.setOpenSubmenuFor(null) + if (mentionFolderNav?.isInFolder) { + mentionFolderNav.closeFolder() mentionMenu.setSubmenuQueryStart(null) + } else if (slashFolderNav?.isInFolder) { + slashFolderNav.closeFolder() } else { mentionMenu.closeMentionMenu() setShowSlashMenu(false) @@ -407,18 +395,19 @@ const UserInput = forwardRef( const query = activeSlash?.query.trim().toLowerCase() || '' const showAggregatedView = query.length > 0 const direction = e.key === 'ArrowDown' ? 'down' : 'up' + const isInFolder = slashFolderNav?.isInFolder ?? false if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault() - if (mentionMenu.openSubmenuFor === 'Web') { + if (isInFolder) { mentionMenu.setSubmenuActiveIndex((prev) => { const next = getNextIndex(prev, direction, WEB_COMMANDS.length - 1) requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next)) return next }) } else if (showAggregatedView) { - const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query)) + const filtered = ALL_COMMAND_IDS.filter((cmd) => cmd.includes(query)) mentionMenu.setSubmenuActiveIndex((prev) => { if (filtered.length === 0) return 0 const next = getNextIndex(prev, direction, filtered.length - 1) @@ -437,10 +426,9 @@ const UserInput = forwardRef( if (e.key === 'ArrowRight') { e.preventDefault() - if (!showAggregatedView && !mentionMenu.openSubmenuFor) { + if (!showAggregatedView && !isInFolder) { if (mentionMenu.mentionActiveIndex === TOP_LEVEL_COMMANDS.length) { - mentionMenu.setOpenSubmenuFor('Web') - mentionMenu.setSubmenuActiveIndex(0) + slashFolderNav?.openWebFolder() } } return @@ -448,8 +436,8 @@ const UserInput = forwardRef( if (e.key === 'ArrowLeft') { e.preventDefault() - if (mentionMenu.openSubmenuFor) { - mentionMenu.setOpenSubmenuFor(null) + if (isInFolder) { + slashFolderNav?.closeFolder() } return } @@ -466,13 +454,14 @@ const UserInput = forwardRef( const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message) const query = activeSlash?.query.trim().toLowerCase() || '' const showAggregatedView = query.length > 0 + const isInFolder = slashFolderNav?.isInFolder ?? false - if (mentionMenu.openSubmenuFor === 'Web') { + if (isInFolder) { const selectedCommand = - WEB_COMMANDS[mentionMenu.submenuActiveIndex] || WEB_COMMANDS[0] + WEB_COMMANDS[mentionMenu.submenuActiveIndex]?.id || WEB_COMMANDS[0].id handleSlashCommandSelect(selectedCommand) } else if (showAggregatedView) { - const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query)) + const filtered = ALL_COMMAND_IDS.filter((cmd) => cmd.includes(query)) if (filtered.length > 0) { const selectedCommand = filtered[mentionMenu.submenuActiveIndex] || filtered[0] handleSlashCommandSelect(selectedCommand) @@ -480,10 +469,9 @@ const UserInput = forwardRef( } else { const selectedIndex = mentionMenu.mentionActiveIndex if (selectedIndex < TOP_LEVEL_COMMANDS.length) { - handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex]) + handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex].id) } else if (selectedIndex === TOP_LEVEL_COMMANDS.length) { - mentionMenu.setOpenSubmenuFor('Web') - mentionMenu.setSubmenuActiveIndex(0) + slashFolderNav?.openWebFolder() } } return @@ -568,6 +556,8 @@ const UserInput = forwardRef( message, mentionTokensWithContext, showSlashMenu, + slashFolderNav, + mentionFolderNav, ] ) @@ -586,7 +576,7 @@ const UserInput = forwardRef( setShowSlashMenu(false) mentionMenu.setShowMentionMenu(true) mentionMenu.setInAggregated(false) - if (mentionMenu.openSubmenuFor) { + if (mentionFolderNav?.isInFolder) { mentionMenu.setSubmenuActiveIndex(0) } else { mentionMenu.setMentionActiveIndex(0) @@ -605,7 +595,7 @@ const UserInput = forwardRef( setShowSlashMenu(false) } }, - [setMessage, mentionMenu, disableMentions] + [setMessage, mentionMenu, disableMentions, mentionFolderNav] ) const handleSelectAdjust = useCallback(() => { @@ -838,6 +828,7 @@ const UserInput = forwardRef( mentionData={mentionData} message={message} insertHandlers={insertHandlers} + onFolderNavChange={setMentionFolderNav} />, document.body )} @@ -850,6 +841,7 @@ const UserInput = forwardRef( mentionMenu={mentionMenu} message={message} onSelectCommand={handleSlashCommandSelect} + onFolderNavChange={setSlashFolderNav} />, document.body )} 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 new file mode 100644 index 0000000000..89902729d4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts @@ -0,0 +1,149 @@ +import { + FOLDER_CONFIGS, + type MentionFolderId, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' +import type { MentionDataReturn } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data' +import type { ChatContext } from '@/stores/panel' + +/** + * Gets the data array for a folder ID from mentionData. + * Uses FOLDER_CONFIGS as the source of truth for key mapping. + * Returns any[] since item types vary by folder and are used with dynamic config.filterFn + */ +export function getFolderData(mentionData: MentionDataReturn, folderId: MentionFolderId): any[] { + const config = FOLDER_CONFIGS[folderId] + return (mentionData[config.dataKey as keyof MentionDataReturn] as any[]) || [] +} + +/** + * Gets the loading state for a folder ID from mentionData. + * Uses FOLDER_CONFIGS as the source of truth for key mapping. + */ +export function getFolderLoading( + mentionData: MentionDataReturn, + folderId: MentionFolderId +): boolean { + const config = FOLDER_CONFIGS[folderId] + return mentionData[config.loadingKey as keyof MentionDataReturn] as boolean +} + +/** + * Gets the ensure loaded function for a folder ID from mentionData. + * Uses FOLDER_CONFIGS as the source of truth for key mapping. + */ +export function getFolderEnsureLoaded( + mentionData: MentionDataReturn, + folderId: MentionFolderId +): (() => Promise) | undefined { + const config = FOLDER_CONFIGS[folderId] + if (!config.ensureLoadedKey) return undefined + return mentionData[config.ensureLoadedKey as keyof MentionDataReturn] as + | (() => Promise) + | undefined +} + +/** + * Extract specific ChatContext types for type-safe narrowing + */ +type PastChatContext = Extract +type WorkflowContext = Extract +type CurrentWorkflowContext = Extract +type BlocksContext = Extract +type WorkflowBlockContext = Extract +type KnowledgeContext = Extract +type TemplatesContext = Extract +type LogsContext = Extract +type SlashCommandContext = Extract + +/** + * Checks if two contexts of the same kind are equal by their ID fields. + * Assumes c.kind === context.kind (must be checked before calling). + */ +export function areContextsEqual(c: ChatContext, context: ChatContext): boolean { + switch (c.kind) { + case 'past_chat': { + const ctx = context as PastChatContext + return c.chatId === ctx.chatId + } + case 'workflow': { + const ctx = context as WorkflowContext + return c.workflowId === ctx.workflowId + } + case 'current_workflow': { + const ctx = context as CurrentWorkflowContext + return c.workflowId === ctx.workflowId + } + case 'blocks': { + const ctx = context as BlocksContext + const existingIds = c.blockIds || [] + const newIds = ctx.blockIds || [] + return existingIds.some((id) => newIds.includes(id)) + } + case 'workflow_block': { + const ctx = context as WorkflowBlockContext + return c.workflowId === ctx.workflowId && c.blockId === ctx.blockId + } + case 'knowledge': { + const ctx = context as KnowledgeContext + return c.knowledgeId === ctx.knowledgeId + } + case 'templates': { + const ctx = context as TemplatesContext + return c.templateId === ctx.templateId + } + case 'logs': { + const ctx = context as LogsContext + return c.executionId === ctx.executionId + } + case 'docs': + return true // Only one docs context allowed + case 'slash_command': { + const ctx = context as SlashCommandContext + return c.command === ctx.command + } + default: + return false + } +} + +/** + * Removes a context from a list, returning a new filtered list. + */ +export function filterOutContext( + contexts: ChatContext[], + contextToRemove: ChatContext +): ChatContext[] { + return contexts.filter((c) => { + if (c.kind !== contextToRemove.kind) return true + return !areContextsEqual(c, contextToRemove) + }) +} + +/** + * Checks if a context already exists in selected contexts. + * + * The token system uses @label format, so we cannot have duplicate labels + * regardless of kind or ID differences. + * + * @param context - Context to check + * @param selectedContexts - Currently selected contexts + * @returns True if context already exists or label is already used + */ +export function isContextAlreadySelected( + context: ChatContext, + selectedContexts: ChatContext[] +): boolean { + return selectedContexts.some((c) => { + // CRITICAL: Check label collision FIRST + // The token system uses @label format, so we cannot have duplicate labels + // regardless of kind or ID differences + if (c.label && context.label && c.label === context.label) { + return true + } + + // Secondary check: exact duplicate by ID fields + if (c.kind !== context.kind) return false + + return areContextsEqual(c, context) + }) +} diff --git a/apps/sim/lib/core/utils/formatting.ts b/apps/sim/lib/core/utils/formatting.ts index 5543026f56..3d6d1e9029 100644 --- a/apps/sim/lib/core/utils/formatting.ts +++ b/apps/sim/lib/core/utils/formatting.ts @@ -7,7 +7,6 @@ export function getTimezoneAbbreviation(timezone: string, date: Date = new Date()): string { if (timezone === 'UTC') return 'UTC' - // Common timezone mappings const timezoneMap: Record = { 'America/Los_Angeles': { standard: 'PST', daylight: 'PDT' }, 'America/Denver': { standard: 'MST', daylight: 'MDT' }, @@ -20,30 +19,22 @@ export function getTimezoneAbbreviation(timezone: string, date: Date = new Date( 'Asia/Singapore': { standard: 'SGT', daylight: 'SGT' }, // Singapore doesn't use DST } - // If we have a mapping for this timezone if (timezone in timezoneMap) { - // January 1 is guaranteed to be standard time in northern hemisphere - // July 1 is guaranteed to be daylight time in northern hemisphere (if observed) const januaryDate = new Date(date.getFullYear(), 0, 1) const julyDate = new Date(date.getFullYear(), 6, 1) - // Get offset in January (standard time) const januaryFormatter = new Intl.DateTimeFormat('en-US', { timeZone: timezone, timeZoneName: 'short', }) - // Get offset in July (likely daylight time) const julyFormatter = new Intl.DateTimeFormat('en-US', { timeZone: timezone, timeZoneName: 'short', }) - // If offsets are different, timezone observes DST const isDSTObserved = januaryFormatter.format(januaryDate) !== julyFormatter.format(julyDate) - // If DST is observed, check if current date is in DST by comparing its offset - // with January's offset (standard time) if (isDSTObserved) { const currentFormatter = new Intl.DateTimeFormat('en-US', { timeZone: timezone, @@ -54,11 +45,9 @@ export function getTimezoneAbbreviation(timezone: string, date: Date = new Date( return isDST ? timezoneMap[timezone].daylight : timezoneMap[timezone].standard } - // If DST is not observed, always use standard return timezoneMap[timezone].standard } - // For unknown timezones, use full IANA name return timezone } @@ -79,7 +68,6 @@ export function formatDateTime(date: Date, timezone?: string): string { timeZone: timezone || undefined, }) - // If timezone is provided, add a friendly timezone abbreviation if (timezone) { const tzAbbr = getTimezoneAbbreviation(timezone, date) return `${formattedDate} ${tzAbbr}` @@ -114,6 +102,24 @@ export function formatTime(date: Date): string { }) } +/** + * Format an ISO timestamp into a compact format for UI display + * @param iso - ISO timestamp string + * @returns A formatted string in "MM-DD HH:mm" format + */ +export function formatCompactTimestamp(iso: string): string { + try { + const d = new Date(iso) + const mm = String(d.getMonth() + 1).padStart(2, '0') + const dd = String(d.getDate()).padStart(2, '0') + const hh = String(d.getHours()).padStart(2, '0') + const min = String(d.getMinutes()).padStart(2, '0') + return `${mm}-${dd} ${hh}:${min}` + } catch { + return iso + } +} + /** * Format a duration in milliseconds to a human-readable format * @param durationMs - The duration in milliseconds