diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 8d92bdbb7b..77693eb3c9 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -96,6 +96,7 @@ const ChatMessageSchema = z.object({ }) ) .optional(), + commands: z.array(z.string()).optional(), }) /** @@ -131,6 +132,7 @@ export async function POST(req: NextRequest) { provider, conversationId, contexts, + commands, } = ChatMessageSchema.parse(body) // Ensure we have a consistent user message ID for this request const userMessageIdToUse = userMessageId || crypto.randomUUID() @@ -458,6 +460,7 @@ export async function POST(req: NextRequest) { ...(integrationTools.length > 0 && { tools: integrationTools }), ...(baseTools.length > 0 && { baseTools }), ...(credentials && { credentials }), + ...(commands && commands.length > 0 && { commands }), } try { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx index dcc2dffd06..dc3299c50f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { useEffect, useMemo, useState } from 'react' +import React, { memo, useCallback, useState } from 'react' import { Check, Copy } from 'lucide-react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -28,55 +28,95 @@ const getTextContent = (element: React.ReactNode): string => { return '' } -// Global layout fixes for markdown content inside the copilot panel -if (typeof document !== 'undefined') { - const styleId = 'copilot-markdown-fix' - if (!document.getElementById(styleId)) { - const style = document.createElement('style') - style.id = styleId - style.textContent = ` - /* Prevent any markdown content from expanding beyond the panel */ - .copilot-markdown-wrapper, - .copilot-markdown-wrapper * { - max-width: 100% !important; - } +/** + * Maps common language aliases to supported viewer languages + */ +const LANGUAGE_MAP: Record = { + js: 'javascript', + javascript: 'javascript', + jsx: 'javascript', + ts: 'javascript', + typescript: 'javascript', + tsx: 'javascript', + json: 'json', + python: 'python', + py: 'python', + code: 'javascript', +} - .copilot-markdown-wrapper p, - .copilot-markdown-wrapper li { - overflow-wrap: anywhere !important; - word-break: break-word !important; - } +/** + * Normalizes a language string to a supported viewer language + */ +function normalizeLanguage(lang: string): 'javascript' | 'json' | 'python' { + const normalized = (lang || '').toLowerCase() + return LANGUAGE_MAP[normalized] || 'javascript' +} - .copilot-markdown-wrapper a { - overflow-wrap: anywhere !important; - word-break: break-all !important; - } +/** + * Props for the CodeBlock component + */ +interface CodeBlockProps { + /** Code content to display */ + code: string + /** Language identifier from markdown */ + language: string +} - .copilot-markdown-wrapper code:not(pre code) { - white-space: normal !important; - overflow-wrap: anywhere !important; - word-break: break-word !important; - } +/** + * CodeBlock component with isolated copy state + * Prevents full markdown re-renders when copy button is clicked + */ +const CodeBlock = memo(function CodeBlock({ code, language }: CodeBlockProps) { + const [copied, setCopied] = useState(false) + + const handleCopy = useCallback(() => { + if (code) { + navigator.clipboard.writeText(code) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + }, [code]) - /* Reduce top margin for first heading (e.g., right after thinking block) */ - .copilot-markdown-wrapper > h1:first-child, - .copilot-markdown-wrapper > h2:first-child, - .copilot-markdown-wrapper > h3:first-child, - .copilot-markdown-wrapper > h4:first-child { - margin-top: 0.25rem !important; - } - ` - document.head.appendChild(style) - } -} + const viewerLanguage = normalizeLanguage(language) + const displayLanguage = language === 'code' ? viewerLanguage : language + + return ( +
+
+ {displayLanguage} + +
+ +
+ ) +}) /** * Link component with hover preview tooltip - * Displays full URL on hover for better UX - * @param props - Component props with href and children - * @returns Link element with tooltip preview */ -function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) { +const LinkWithPreview = memo(function LinkWithPreview({ + href, + children, +}: { + href: string + children: React.ReactNode +}) { return ( @@ -94,7 +134,7 @@ function LinkWithPreview({ href, children }: { href: string; children: React.Rea ) -} +}) /** * Props for the CopilotMarkdownRenderer component @@ -105,274 +145,196 @@ interface CopilotMarkdownRendererProps { } /** - * CopilotMarkdownRenderer renders markdown content with custom styling - * Supports GitHub-flavored markdown, code blocks with syntax highlighting, - * tables, links with preview, and more - * - * @param props - Component props - * @returns Rendered markdown content + * Static markdown component definitions - optimized for LLM chat spacing + * Tighter spacing compared to traditional prose for better chat UX */ -export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRendererProps) { - const [copiedCodeBlocks, setCopiedCodeBlocks] = useState>({}) - - useEffect(() => { - const timers: Record = {} - - Object.keys(copiedCodeBlocks).forEach((key) => { - if (copiedCodeBlocks[key]) { - timers[key] = setTimeout(() => { - setCopiedCodeBlocks((prev) => ({ ...prev, [key]: false })) - }, 2000) - } - }) - - return () => { - Object.values(timers).forEach(clearTimeout) +const markdownComponents = { + // Paragraphs - tight spacing, no margin on last + p: ({ children }: React.HTMLAttributes) => ( +

+ {children} +

+ ), + + // Headings - minimal margins for chat context + h1: ({ children }: React.HTMLAttributes) => ( +

+ {children} +

+ ), + h2: ({ children }: React.HTMLAttributes) => ( +

+ {children} +

+ ), + h3: ({ children }: React.HTMLAttributes) => ( +

+ {children} +

+ ), + h4: ({ children }: React.HTMLAttributes) => ( +

+ {children} +

+ ), + + // Lists - compact spacing + ul: ({ children }: React.HTMLAttributes) => ( +
    + {children} +
+ ), + ol: ({ children }: React.HTMLAttributes) => ( +
    + {children} +
+ ), + li: ({ children }: React.LiHTMLAttributes) => ( +
  • + {children} +
  • + ), + + // Code blocks - handled by CodeBlock component + pre: ({ children }: React.HTMLAttributes) => { + let codeContent: React.ReactNode = children + let language = 'code' + + if ( + React.isValidElement<{ className?: string; children?: React.ReactNode }>(children) && + children.type === 'code' + ) { + const childElement = children as React.ReactElement<{ + className?: string + children?: React.ReactNode + }> + codeContent = childElement.props.children + language = childElement.props.className?.replace('language-', '') || 'code' } - }, [copiedCodeBlocks]) - - const markdownComponents = useMemo( - () => ({ - p: ({ children }: React.HTMLAttributes) => ( -

    - {children} -

    - ), - h1: ({ children }: React.HTMLAttributes) => ( -

    - {children} -

    - ), - h2: ({ children }: React.HTMLAttributes) => ( -

    - {children} -

    - ), - h3: ({ children }: React.HTMLAttributes) => ( -

    - {children} -

    - ), - h4: ({ children }: React.HTMLAttributes) => ( -

    - {children} -

    - ), - - ul: ({ children }: React.HTMLAttributes) => ( -
      - {children} -
    - ), - ol: ({ children }: React.HTMLAttributes) => ( -
      - {children} -
    - ), - li: ({ - children, - ordered, - }: React.LiHTMLAttributes & { ordered?: boolean }) => ( -
  • - {children} -
  • - ), - - pre: ({ children }: React.HTMLAttributes) => { - let codeContent: React.ReactNode = children - let language = 'code' - - if ( - React.isValidElement<{ className?: string; children?: React.ReactNode }>(children) && - children.type === 'code' - ) { - const childElement = children as React.ReactElement<{ - className?: string - children?: React.ReactNode - }> - codeContent = childElement.props.children - language = childElement.props.className?.replace('language-', '') || 'code' - } - - let actualCodeText = '' - if (typeof codeContent === 'string') { - actualCodeText = codeContent - } else if (React.isValidElement(codeContent)) { - actualCodeText = getTextContent(codeContent) - } else if (Array.isArray(codeContent)) { - actualCodeText = codeContent - .map((child) => - typeof child === 'string' - ? child - : React.isValidElement(child) - ? getTextContent(child) - : '' - ) - .join('') - } else { - actualCodeText = String(codeContent || '') - } - - const codeText = actualCodeText || 'code' - const codeBlockKey = `${language}-${codeText.substring(0, 30).replace(/\s/g, '-')}-${codeText.length}` - - const showCopySuccess = copiedCodeBlocks[codeBlockKey] || false - - const handleCopy = () => { - const textToCopy = actualCodeText - if (textToCopy) { - navigator.clipboard.writeText(textToCopy) - setCopiedCodeBlocks((prev) => ({ ...prev, [codeBlockKey]: true })) - } - } - - const normalizedLanguage = (language || '').toLowerCase() - const viewerLanguage: 'javascript' | 'json' | 'python' = - normalizedLanguage === 'json' - ? 'json' - : normalizedLanguage === 'python' || normalizedLanguage === 'py' - ? 'python' - : 'javascript' - - return ( -
    -
    - - {language === 'code' ? viewerLanguage : language} - - -
    - -
    + let actualCodeText = '' + if (typeof codeContent === 'string') { + actualCodeText = codeContent + } else if (React.isValidElement(codeContent)) { + actualCodeText = getTextContent(codeContent) + } else if (Array.isArray(codeContent)) { + actualCodeText = codeContent + .map((child) => + typeof child === 'string' + ? child + : React.isValidElement(child) + ? getTextContent(child) + : '' ) - }, - - code: ({ - inline, - className, - children, - ...props - }: React.HTMLAttributes & { className?: string; inline?: boolean }) => { - if (inline) { - return ( - - {children} - - ) - } - return ( - - {children} - - ) - }, - - strong: ({ children }: React.HTMLAttributes) => ( - {children} - ), - - b: ({ children }: React.HTMLAttributes) => ( - {children} - ), - - em: ({ children }: React.HTMLAttributes) => ( - {children} - ), - - i: ({ children }: React.HTMLAttributes) => ( - {children} - ), - - blockquote: ({ children }: React.HTMLAttributes) => ( -
    - {children} -
    - ), - - hr: () =>
    , - - a: ({ href, children, ...props }: React.AnchorHTMLAttributes) => ( - - {children} - - ), - - table: ({ children }: React.TableHTMLAttributes) => ( -
    - - {children} -
    -
    - ), - thead: ({ children }: React.HTMLAttributes) => ( - - {children} - - ), - tbody: ({ children }: React.HTMLAttributes) => ( - {children} - ), - tr: ({ children }: React.HTMLAttributes) => ( - - {children} - - ), - th: ({ children }: React.ThHTMLAttributes) => ( - - {children} - - ), - td: ({ children }: React.TdHTMLAttributes) => ( - - {children} - - ), + .join('') + } else { + actualCodeText = String(codeContent || '') + } - img: ({ src, alt, ...props }: React.ImgHTMLAttributes) => ( - {alt - ), - }), - [copiedCodeBlocks] - ) + return + }, + + // Inline code + code: ({ + className, + children, + ...props + }: React.HTMLAttributes & { className?: string }) => ( + + {children} + + ), + + // Text formatting + strong: ({ children }: React.HTMLAttributes) => ( + {children} + ), + b: ({ children }: React.HTMLAttributes) => ( + {children} + ), + em: ({ children }: React.HTMLAttributes) => ( + {children} + ), + i: ({ children }: React.HTMLAttributes) => ( + {children} + ), + + // Blockquote - compact + blockquote: ({ children }: React.HTMLAttributes) => ( +
    + {children} +
    + ), + + // Horizontal rule + hr: () =>
    , + + // Links + a: ({ href, children }: React.AnchorHTMLAttributes) => ( + {children} + ), + + // Tables - compact + table: ({ children }: React.TableHTMLAttributes) => ( +
    + + {children} +
    +
    + ), + thead: ({ children }: React.HTMLAttributes) => ( + {children} + ), + tbody: ({ children }: React.HTMLAttributes) => ( + {children} + ), + tr: ({ children }: React.HTMLAttributes) => ( + {children} + ), + th: ({ children }: React.ThHTMLAttributes) => ( + + {children} + + ), + td: ({ children }: React.TdHTMLAttributes) => ( + + {children} + + ), + + // Images + img: ({ src, alt, ...props }: React.ImgHTMLAttributes) => ( + {alt + ), +} +/** + * CopilotMarkdownRenderer renders markdown content with custom styling + * Optimized for LLM chat: tight spacing, memoized components, isolated state + * + * @param props - Component props + * @returns Rendered markdown content + */ +function CopilotMarkdownRenderer({ content }: CopilotMarkdownRendererProps) { return ( -
    +
    {content}
    ) } + +export default memo(CopilotMarkdownRenderer) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming.tsx index 71de980ce9..7dfe9af4ea 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming.tsx @@ -2,18 +2,38 @@ import { memo, useEffect, useRef, useState } from 'react' import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer' /** - * Character animation delay in milliseconds + * Minimum delay between characters (fast catch-up mode) */ -const CHARACTER_DELAY = 3 +const MIN_DELAY = 1 + +/** + * Maximum delay between characters (when waiting for content) + */ +const MAX_DELAY = 12 + +/** + * Default delay when streaming normally + */ +const DEFAULT_DELAY = 4 + +/** + * How far behind (in characters) before we speed up + */ +const CATCH_UP_THRESHOLD = 20 + +/** + * How close to content before we slow down + */ +const SLOW_DOWN_THRESHOLD = 5 /** * StreamingIndicator shows animated dots during message streaming - * Uses CSS classes for animations to follow best practices + * Used as a standalone indicator when no content has arrived yet * * @returns Animated loading indicator */ export const StreamingIndicator = memo(() => ( -
    +
    @@ -34,9 +54,39 @@ interface SmoothStreamingTextProps { isStreaming: boolean } +/** + * Calculates adaptive delay based on how far behind animation is from actual content + * + * @param displayedLength - Current displayed content length + * @param totalLength - Total available content length + * @returns Delay in milliseconds + */ +function calculateAdaptiveDelay(displayedLength: number, totalLength: number): number { + const charsRemaining = totalLength - displayedLength + + if (charsRemaining > CATCH_UP_THRESHOLD) { + // Far behind - speed up to catch up + // Scale from MIN_DELAY to DEFAULT_DELAY based on how far behind + const catchUpFactor = Math.min(1, (charsRemaining - CATCH_UP_THRESHOLD) / 50) + return MIN_DELAY + (DEFAULT_DELAY - MIN_DELAY) * (1 - catchUpFactor) + } + + if (charsRemaining <= SLOW_DOWN_THRESHOLD) { + // Close to content edge - slow down to feel natural + // The closer we are, the slower we go (up to MAX_DELAY) + const slowFactor = 1 - charsRemaining / SLOW_DOWN_THRESHOLD + return DEFAULT_DELAY + (MAX_DELAY - DEFAULT_DELAY) * slowFactor + } + + // Normal streaming speed + return DEFAULT_DELAY +} + /** * SmoothStreamingText component displays text with character-by-character animation - * Creates a smooth streaming effect for AI responses + * Creates a smooth streaming effect for AI responses with adaptive speed + * + * Uses adaptive pacing: speeds up when catching up, slows down near content edge * * @param props - Component props * @returns Streaming text with smooth animation @@ -45,74 +95,73 @@ export const SmoothStreamingText = memo( ({ content, isStreaming }: SmoothStreamingTextProps) => { const [displayedContent, setDisplayedContent] = useState('') const contentRef = useRef(content) - const timeoutRef = useRef(null) + const rafRef = useRef(null) const indexRef = useRef(0) - const streamingStartTimeRef = useRef(null) + const lastFrameTimeRef = useRef(0) const isAnimatingRef = useRef(false) - /** - * Handles content streaming animation - * Updates displayed content character by character during streaming - */ useEffect(() => { contentRef.current = content if (content.length === 0) { setDisplayedContent('') indexRef.current = 0 - streamingStartTimeRef.current = null return } if (isStreaming) { - if (streamingStartTimeRef.current === null) { - streamingStartTimeRef.current = Date.now() - } + if (indexRef.current < content.length && !isAnimatingRef.current) { + isAnimatingRef.current = true + lastFrameTimeRef.current = performance.now() - if (indexRef.current < content.length) { - const animateText = () => { + const animateText = (timestamp: number) => { const currentContent = contentRef.current const currentIndex = indexRef.current + const elapsed = timestamp - lastFrameTimeRef.current + + // Calculate adaptive delay based on how far behind we are + const delay = calculateAdaptiveDelay(currentIndex, currentContent.length) + + if (elapsed >= delay) { + if (currentIndex < currentContent.length) { + const newDisplayed = currentContent.slice(0, currentIndex + 1) + setDisplayedContent(newDisplayed) + indexRef.current = currentIndex + 1 + lastFrameTimeRef.current = timestamp + } + } - if (currentIndex < currentContent.length) { - const chunkSize = 1 - const newDisplayed = currentContent.slice(0, currentIndex + chunkSize) - - setDisplayedContent(newDisplayed) - indexRef.current = currentIndex + chunkSize - - timeoutRef.current = setTimeout(animateText, CHARACTER_DELAY) + if (indexRef.current < currentContent.length) { + rafRef.current = requestAnimationFrame(animateText) } else { isAnimatingRef.current = false } } - if (!isAnimatingRef.current) { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current) - } - - isAnimatingRef.current = true - animateText() - } + rafRef.current = requestAnimationFrame(animateText) + } else if (indexRef.current < content.length && isAnimatingRef.current) { + // Animation already running, it will pick up new content automatically } } else { + // Streaming ended - show full content immediately + if (rafRef.current) { + cancelAnimationFrame(rafRef.current) + } setDisplayedContent(content) indexRef.current = content.length isAnimatingRef.current = false - streamingStartTimeRef.current = null } return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current) + if (rafRef.current) { + cancelAnimationFrame(rafRef.current) } isAnimatingRef.current = false } }, [content, isStreaming]) return ( -
    +
    ) @@ -121,7 +170,6 @@ export const SmoothStreamingText = memo( // Prevent re-renders during streaming unless content actually changed return ( prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming - // markdownComponents is now memoized so no need to compare ) } ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx index 54c7042e75..fbb7065f90 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { ChevronUp } from 'lucide-react' import CopilotMarkdownRenderer from './markdown-renderer' @@ -8,18 +8,151 @@ import CopilotMarkdownRenderer from './markdown-renderer' /** * Max height for thinking content before internal scrolling kicks in */ -const THINKING_MAX_HEIGHT = 200 +const THINKING_MAX_HEIGHT = 150 + +/** + * Height threshold before gradient fade kicks in + */ +const GRADIENT_THRESHOLD = 100 /** * Interval for auto-scroll during streaming (ms) */ -const SCROLL_INTERVAL = 100 +const SCROLL_INTERVAL = 50 /** * Timer update interval in milliseconds */ const TIMER_UPDATE_INTERVAL = 100 +/** + * Thinking text streaming - much faster than main text + * Essentially instant with minimal delay + */ +const THINKING_DELAY = 0.5 +const THINKING_CHARS_PER_FRAME = 3 + +/** + * Props for the SmoothThinkingText component + */ +interface SmoothThinkingTextProps { + content: string + isStreaming: boolean +} + +/** + * SmoothThinkingText renders thinking content with fast streaming animation + * Uses gradient fade at top when content is tall enough + */ +const SmoothThinkingText = memo( + ({ content, isStreaming }: SmoothThinkingTextProps) => { + const [displayedContent, setDisplayedContent] = useState('') + const [showGradient, setShowGradient] = useState(false) + const contentRef = useRef(content) + const textRef = useRef(null) + const rafRef = useRef(null) + const indexRef = useRef(0) + const lastFrameTimeRef = useRef(0) + const isAnimatingRef = useRef(false) + + useEffect(() => { + contentRef.current = content + + if (content.length === 0) { + setDisplayedContent('') + indexRef.current = 0 + return + } + + if (isStreaming) { + if (indexRef.current < content.length && !isAnimatingRef.current) { + isAnimatingRef.current = true + lastFrameTimeRef.current = performance.now() + + const animateText = (timestamp: number) => { + const currentContent = contentRef.current + const currentIndex = indexRef.current + const elapsed = timestamp - lastFrameTimeRef.current + + if (elapsed >= THINKING_DELAY) { + if (currentIndex < currentContent.length) { + // Reveal multiple characters per frame for faster streaming + const newIndex = Math.min( + currentIndex + THINKING_CHARS_PER_FRAME, + currentContent.length + ) + const newDisplayed = currentContent.slice(0, newIndex) + setDisplayedContent(newDisplayed) + indexRef.current = newIndex + lastFrameTimeRef.current = timestamp + } + } + + if (indexRef.current < currentContent.length) { + rafRef.current = requestAnimationFrame(animateText) + } else { + isAnimatingRef.current = false + } + } + + rafRef.current = requestAnimationFrame(animateText) + } + } else { + // Streaming ended - show full content immediately + if (rafRef.current) { + cancelAnimationFrame(rafRef.current) + } + setDisplayedContent(content) + indexRef.current = content.length + isAnimatingRef.current = false + } + + return () => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current) + } + isAnimatingRef.current = false + } + }, [content, isStreaming]) + + // Check if content height exceeds threshold for gradient + useEffect(() => { + if (textRef.current && isStreaming) { + const height = textRef.current.scrollHeight + setShowGradient(height > GRADIENT_THRESHOLD) + } else { + setShowGradient(false) + } + }, [displayedContent, isStreaming]) + + // Apply vertical gradient fade at the top only when content is tall enough + const gradientStyle = + isStreaming && showGradient + ? { + maskImage: 'linear-gradient(to bottom, transparent 0%, black 30%, black 100%)', + WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, black 30%, black 100%)', + } + : undefined + + return ( +
    + +
    + ) + }, + (prevProps, nextProps) => { + return ( + prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming + ) + } +) + +SmoothThinkingText.displayName = 'SmoothThinkingText' + /** * Props for the ThinkingBlock component */ @@ -66,8 +199,8 @@ export function ThinkingBlock({ * Auto-collapses when streaming ends OR when following content arrives */ useEffect(() => { - // Collapse if streaming ended or if there's following content (like a tool call) - if (!isStreaming || hasFollowingContent) { + // Collapse if streaming ended, there's following content, or special tags arrived + if (!isStreaming || hasFollowingContent || hasSpecialTags) { setIsExpanded(false) userCollapsedRef.current = false setUserHasScrolledAway(false) @@ -77,7 +210,7 @@ export function ThinkingBlock({ if (!userCollapsedRef.current && content && content.trim().length > 0) { setIsExpanded(true) } - }, [isStreaming, content, hasFollowingContent]) + }, [isStreaming, content, hasFollowingContent, hasSpecialTags]) // Reset start time when streaming begins useEffect(() => { @@ -113,14 +246,14 @@ export function ThinkingBlock({ const isNearBottom = distanceFromBottom <= 20 const delta = scrollTop - lastScrollTopRef.current - const movedUp = delta < -2 + const movedUp = delta < -1 if (movedUp && !isNearBottom) { setUserHasScrolledAway(true) } - // Re-stick if user scrolls back to bottom - if (userHasScrolledAway && isNearBottom) { + // Re-stick if user scrolls back to bottom with intent + if (userHasScrolledAway && isNearBottom && delta > 10) { setUserHasScrolledAway(false) } @@ -133,7 +266,7 @@ export function ThinkingBlock({ return () => container.removeEventListener('scroll', handleScroll) }, [isExpanded, userHasScrolledAway]) - // Smart auto-scroll: only scroll if user hasn't scrolled away + // Smart auto-scroll: always scroll to bottom while streaming unless user scrolled away useEffect(() => { if (!isStreaming || !isExpanded || userHasScrolledAway) return @@ -141,20 +274,14 @@ export function ThinkingBlock({ const container = scrollContainerRef.current if (!container) return - const { scrollTop, scrollHeight, clientHeight } = container - const distanceFromBottom = scrollHeight - scrollTop - clientHeight - const isNearBottom = distanceFromBottom <= 50 - - if (isNearBottom) { - programmaticScrollRef.current = true - container.scrollTo({ - top: container.scrollHeight, - behavior: 'smooth', - }) - window.setTimeout(() => { - programmaticScrollRef.current = false - }, 150) - } + programmaticScrollRef.current = true + container.scrollTo({ + top: container.scrollHeight, + behavior: 'auto', + }) + window.setTimeout(() => { + programmaticScrollRef.current = false + }, 16) }, SCROLL_INTERVAL) return () => window.clearInterval(intervalId) @@ -241,15 +368,11 @@ export function ThinkingBlock({
    - {/* Render markdown during streaming with thinking text styling */} -
    - - -
    +
    ) @@ -281,12 +404,12 @@ export function ThinkingBlock({
    - {/* Use markdown renderer for completed content */} -
    + {/* Completed thinking text - dimmed with markdown */} +
    diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx index 2cba10be86..be3af2f886 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx @@ -187,6 +187,7 @@ const CopilotMessage: FC = memo( ) // Memoize content blocks to avoid re-rendering unchanged blocks + // No entrance animations to prevent layout shift const memoizedContentBlocks = useMemo(() => { if (!message.contentBlocks || message.contentBlocks.length === 0) { return null @@ -205,14 +206,10 @@ const CopilotMessage: FC = memo( // Use smooth streaming for the last text block if we're streaming const shouldUseSmoothing = isStreaming && isLastTextBlock + const blockKey = `text-${index}-${block.timestamp || index}` return ( -
    0 ? 'opacity-100' : 'opacity-70' - } ${shouldUseSmoothing ? 'translate-y-0 transition-transform duration-100 ease-out' : ''}`} - > +
    {shouldUseSmoothing ? ( ) : ( @@ -224,29 +221,33 @@ const CopilotMessage: FC = memo( if (block.type === 'thinking') { // Check if there are any blocks after this one (tool calls, text, etc.) const hasFollowingContent = index < message.contentBlocks!.length - 1 + // Check if special tags (options, plan) are present - should also close thinking + const hasSpecialTags = !!(parsedTags?.options || parsedTags?.plan) + const blockKey = `thinking-${index}-${block.timestamp || index}` + return ( -
    +
    ) } if (block.type === 'tool_call') { + const blockKey = `tool-${block.toolCall.id}` + return ( -
    +
    ) } return null }) - }, [message.contentBlocks, isStreaming]) + }, [message.contentBlocks, isStreaming, parsedTags]) if (isUser) { return ( @@ -279,6 +280,7 @@ const CopilotMessage: FC = memo( onModeChange={setMode} panelWidth={panelWidth} clearOnSubmit={false} + initialContexts={message.contexts} /> {/* Inline Checkpoint Discard Confirmation - shown below input in edit mode */} @@ -346,14 +348,18 @@ const CopilotMessage: FC = memo( const contexts: any[] = Array.isArray((message as any).contexts) ? ((message as any).contexts as any[]) : [] - const labels = contexts - .filter((c) => c?.kind !== 'current_workflow') - .map((c) => c?.label) - .filter(Boolean) as string[] - if (!labels.length) return text + + // Build tokens with their prefixes (@ for mentions, / for commands) + const tokens = contexts + .filter((c) => c?.kind !== 'current_workflow' && c?.label) + .map((c) => { + const prefix = c?.kind === 'slash_command' ? '/' : '@' + return `${prefix}${c.label}` + }) + if (!tokens.length) return text const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const pattern = new RegExp(`@(${labels.map(escapeRegex).join('|')})`, 'g') + const pattern = new RegExp(`(${tokens.map(escapeRegex).join('|')})`, 'g') const nodes: React.ReactNode[] = [] let lastIndex = 0 @@ -460,17 +466,29 @@ const CopilotMessage: FC = memo( ) } + // Check if there's any visible content in the blocks + const hasVisibleContent = useMemo(() => { + if (!message.contentBlocks || message.contentBlocks.length === 0) return false + return message.contentBlocks.some((block) => { + if (block.type === 'text') { + const parsed = parseSpecialTags(block.content) + return parsed.cleanContent.trim().length > 0 + } + return block.type === 'thinking' || block.type === 'tool_call' + }) + }, [message.contentBlocks]) + if (isAssistant) { return (
    -
    +
    {/* Content blocks in chronological order */} {memoizedContentBlocks} - {/* Always show streaming indicator at the end while streaming */} + {/* Streaming indicator always at bottom during streaming */} {isStreaming && } {message.errorType === 'usage_limit' && ( 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 4f921c898e..0cf54c016d 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 @@ -497,6 +497,11 @@ const ACTION_VERBS = [ 'Accessed', 'Managing', 'Managed', + 'Scraping', + 'Scraped', + 'Crawling', + 'Crawled', + 'Getting', ] as const /** @@ -1061,7 +1066,7 @@ function SubAgentContent({
    @@ -1157,10 +1162,10 @@ function SubAgentThinkingContent({ /** * Subagents that should collapse when done streaming. - * Default behavior is to NOT collapse (stay expanded like edit). - * Only these specific subagents collapse into "Planned for Xs >" style headers. + * Default behavior is to NOT collapse (stay expanded like edit, superagent, info, etc.). + * Only plan, debug, and research collapse into summary headers. */ -const COLLAPSIBLE_SUBAGENTS = new Set(['plan', 'debug', 'research', 'info']) +const COLLAPSIBLE_SUBAGENTS = new Set(['plan', 'debug', 'research']) /** * SubagentContentRenderer handles the rendering of subagent content. @@ -1321,7 +1326,7 @@ function SubagentContentRenderer({
    @@ -1631,10 +1636,8 @@ function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) { * Checks if a tool is an integration tool (server-side executed, not a client tool) */ function isIntegrationTool(toolName: string): boolean { - // Check if it's NOT a client tool (not in CLASS_TOOL_METADATA and not in registered tools) - const isClientTool = !!CLASS_TOOL_METADATA[toolName] - const isRegisteredTool = !!getRegisteredTools()[toolName] - return !isClientTool && !isRegisteredTool + // Any tool NOT in CLASS_TOOL_METADATA is an integration tool (server-side execution) + return !CLASS_TOOL_METADATA[toolName] } function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean { @@ -1663,16 +1666,9 @@ function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean { return true } - // Also show buttons for integration tools in pending state (they need user confirmation) - // But NOT if the tool is auto-allowed (it will auto-execute) + // Always show buttons for integration tools in pending state (they need user confirmation) const mode = useCopilotStore.getState().mode - const isAutoAllowed = useCopilotStore.getState().isToolAutoAllowed(toolCall.name) - if ( - mode === 'build' && - isIntegrationTool(toolCall.name) && - toolCall.state === 'pending' && - !isAutoAllowed - ) { + if (mode === 'build' && isIntegrationTool(toolCall.name) && toolCall.state === 'pending') { return true } @@ -1894,15 +1890,20 @@ function RunSkipButtons({ if (buttonsHidden) return null - // Standardized buttons for all interrupt tools: Allow, Always Allow, Skip + // Hide "Always Allow" for integration tools (only show for client tools with interrupts) + const showAlwaysAllow = !isIntegrationTool(toolCall.name) + + // Standardized buttons for all interrupt tools: Allow, (Always Allow for client tools only), Skip return (
    - + {showAlwaysAllow && ( + + )} @@ -1968,6 +1969,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: 'tour', 'info', 'workflow', + 'superagent', ] const isSubagentTool = SUBAGENT_TOOLS.includes(toolCall.name) @@ -2595,16 +2597,23 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: } } + // For edit_workflow, hide text display when we have operations (WorkflowEditSummary replaces it) + const isEditWorkflow = toolCall.name === 'edit_workflow' + const hasOperations = Array.isArray(params.operations) && params.operations.length > 0 + const hideTextForEditWorkflow = isEditWorkflow && hasOperations + return (
    -
    - -
    + {!hideTextForEditWorkflow && ( +
    + +
    + )} {isExpandableTool && expanded &&
    {renderPendingDetails()}
    } {showRemoveAutoAllow && isAutoAllowed && (
    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 fd7d64cff1..bab808a85b 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 @@ -3,3 +3,4 @@ export { ContextPills } from './context-pills/context-pills' export { 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' 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 new file mode 100644 index 0000000000..a50de3c1bd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/slash-menu/slash-menu.tsx @@ -0,0 +1,249 @@ +'use client' + +import { useMemo } from 'react' +import { + Popover, + PopoverAnchor, + PopoverBackButton, + PopoverContent, + PopoverFolder, + PopoverItem, + PopoverScrollArea, +} from '@/components/emcn' +import type { useMentionMenu } from '../../hooks/use-mention-menu' + +/** + * Top-level slash command options + */ +const TOP_LEVEL_COMMANDS = [ + { id: 'fast', label: 'fast' }, + { id: 'plan', label: 'plan' }, + { id: 'debug', label: 'debug' }, + { id: 'research', label: 'research' }, + { id: 'deploy', label: 'deploy' }, + { id: 'superagent', label: 'superagent' }, +] as const + +/** + * Web submenu commands + */ +const WEB_COMMANDS = [ + { id: 'search', label: 'search' }, + { id: 'read', label: 'read' }, + { id: 'scrape', label: 'scrape' }, + { id: 'crawl', label: 'crawl' }, +] as const + +/** + * All command labels for filtering + */ +const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS] + +interface SlashMenuProps { + mentionMenu: ReturnType + message: string + onSelectCommand: (command: string) => void +} + +/** + * SlashMenu component for slash command dropdown. + * Shows command options when user types '/'. + * + * @param props - Component props + * @returns Rendered slash menu + */ +export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuProps) { + const { + mentionMenuRef, + menuListRef, + getActiveSlashQueryAtPosition, + getCaretPos, + submenuActiveIndex, + mentionActiveIndex, + openSubmenuFor, + setOpenSubmenuFor, + } = mentionMenu + + /** + * Get the current query string after / + */ + const currentQuery = useMemo(() => { + const caretPos = getCaretPos() + const active = getActiveSlashQueryAtPosition(caretPos, message) + return active?.query.trim().toLowerCase() || '' + }, [message, getCaretPos, getActiveSlashQueryAtPosition]) + + /** + * Filter commands based on query (search across all commands when there's a query) + */ + const filteredCommands = useMemo(() => { + if (!currentQuery) return null // Show folder view when no query + return ALL_COMMANDS.filter((cmd) => cmd.label.toLowerCase().includes(currentQuery)) + }, [currentQuery]) + + // Show aggregated view when there's a query + const showAggregatedView = currentQuery.length > 0 + + // Compute caret viewport position via mirror technique for precise anchoring + const textareaEl = mentionMenu.textareaRef.current + if (!textareaEl) return null + + const getCaretViewport = (textarea: HTMLTextAreaElement, caretPosition: number, text: string) => { + const textareaRect = textarea.getBoundingClientRect() + const style = window.getComputedStyle(textarea) + + 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 = text.substring(0, caretPosition) + + 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 leftOffset = markerRect.left - mirrorRect.left - textarea.scrollLeft + const topOffset = markerRect.top - mirrorRect.top - textarea.scrollTop + + return { + left: textareaRect.left + leftOffset, + top: textareaRect.top + topOffset, + } + } + + const caretPos = getCaretPos() + const caretViewport = getCaretViewport(textareaEl, caretPos, message) + + // Decide preferred side based on available space + const margin = 8 + const spaceAbove = caretViewport.top - margin + const spaceBelow = window.innerHeight - caretViewport.top - margin + const side: 'top' | 'bottom' = spaceBelow >= spaceAbove ? 'bottom' : 'top' + + // Check if we're in folder navigation mode (no query, not in submenu) + const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView + + return ( + { + /* controlled externally */ + }} + > + +
    + + e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > + + + {openSubmenuFor === 'Web' ? ( + // Web submenu view + <> + {WEB_COMMANDS.map((cmd, index) => ( + onSelectCommand(cmd.label)} + data-idx={index} + active={index === submenuActiveIndex} + > + {cmd.label} + + ))} + + ) : showAggregatedView ? ( + // Aggregated filtered view + <> + {filteredCommands && filteredCommands.length === 0 ? ( +
    + No commands found +
    + ) : ( + filteredCommands?.map((cmd, index) => ( + onSelectCommand(cmd.label)} + data-idx={index} + active={index === submenuActiveIndex} + > + {cmd.label} + + )) + )} + + ) : ( + // Folder navigation view + <> + {TOP_LEVEL_COMMANDS.map((cmd, index) => ( + onSelectCommand(cmd.label)} + 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.label)}> + {cmd.label} + + ))} + + + )} +
    +
    + + ) +} 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 72aa6067ca..9e85bbeca6 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,9 +1,11 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import type { ChatContext } from '@/stores/panel' interface UseContextManagementProps { /** Current message text */ message: string + /** Initial contexts to populate when editing a message */ + initialContexts?: ChatContext[] } /** @@ -13,8 +15,17 @@ interface UseContextManagementProps { * @param props - Configuration object * @returns Context state and management functions */ -export function useContextManagement({ message }: UseContextManagementProps) { - const [selectedContexts, setSelectedContexts] = useState([]) +export function useContextManagement({ message, initialContexts }: UseContextManagementProps) { + const [selectedContexts, setSelectedContexts] = useState(initialContexts ?? []) + const initializedRef = useRef(false) + + // Initialize with initial contexts when they're first provided (for edit mode) + useEffect(() => { + if (initialContexts && initialContexts.length > 0 && !initializedRef.current) { + setSelectedContexts(initialContexts) + initializedRef.current = true + } + }, [initialContexts]) /** * Adds a context to the selected contexts list, avoiding duplicates @@ -63,6 +74,9 @@ export function useContextManagement({ message }: UseContextManagementProps) { 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 @@ -103,6 +117,8 @@ export function useContextManagement({ message }: UseContextManagementProps) { 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 } @@ -118,7 +134,7 @@ export function useContextManagement({ message }: UseContextManagementProps) { }, []) /** - * Synchronizes selected contexts with inline @label tokens in the message. + * Synchronizes selected contexts with inline @label or /label tokens in the message. * Removes contexts whose labels are no longer present in the message. */ useEffect(() => { @@ -130,17 +146,16 @@ export function useContextManagement({ message }: UseContextManagementProps) { setSelectedContexts((prev) => { if (prev.length === 0) return prev - const presentLabels = new Set() - const labels = prev.map((c) => c.label).filter(Boolean) - - for (const label of labels) { - const token = ` @${label} ` - if (message.includes(token)) { - presentLabels.add(label) - } - } - - const filtered = prev.filter((c) => !!c.label && presentLabels.has(c.label)) + const filtered = prev.filter((c) => { + if (!c.label) return false + // Check for slash command tokens or mention tokens based on kind + const isSlashCommand = c.kind === 'slash_command' + const prefix = isSlashCommand ? '/' : '@' + const tokenWithSpaces = ` ${prefix}${c.label} ` + const tokenAtStart = `${prefix}${c.label} ` + // Token can appear with leading space OR at the start of the message + return message.includes(tokenWithSpaces) || message.startsWith(tokenAtStart) + }) return filtered.length === prev.length ? prev : filtered }) }, [message]) 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 12460c060f..8a07146e05 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 @@ -70,11 +70,25 @@ export function useMentionMenu({ // Ensure '@' starts a token (start or whitespace before) if (atIndex > 0 && !/\s/.test(before.charAt(atIndex - 1))) return null - // Check if this '@' is part of a completed mention token ( @label ) + // Check if this '@' is part of a completed mention token if (selectedContexts.length > 0) { - const labels = selectedContexts.map((c) => c.label).filter(Boolean) as string[] - for (const label of labels) { - // Space-wrapped token: " @label " + // Only check non-slash_command contexts for mentions + const mentionLabels = selectedContexts + .filter((c) => c.kind !== 'slash_command') + .map((c) => c.label) + .filter(Boolean) as string[] + + for (const label of mentionLabels) { + // Check for token at start of text: "@label " + if (atIndex === 0) { + const startToken = `@${label} ` + if (text.startsWith(startToken)) { + // This @ is part of a completed token + return null + } + } + + // Check for space-wrapped token: " @label " const token = ` @${label} ` let fromIndex = 0 while (fromIndex <= text.length) { @@ -88,7 +102,6 @@ export function useMentionMenu({ // Check if the @ we found is the @ of this completed token if (atIndex === atPositionInToken) { // The @ we found is part of a completed mention - // Don't show menu - user is typing after the completed mention return null } @@ -113,6 +126,76 @@ export function useMentionMenu({ [message, selectedContexts] ) + /** + * Finds active slash command query at the given position + * + * @param pos - Position in the text to check + * @param textOverride - Optional text override (for checking during input) + * @returns Active slash query object or null if no active slash command + */ + const getActiveSlashQueryAtPosition = useCallback( + (pos: number, textOverride?: string) => { + const text = textOverride ?? message + const before = text.slice(0, pos) + const slashIndex = before.lastIndexOf('/') + if (slashIndex === -1) return null + + // Ensure '/' starts a token (start or whitespace before) + if (slashIndex > 0 && !/\s/.test(before.charAt(slashIndex - 1))) return null + + // Check if this '/' is part of a completed slash token + if (selectedContexts.length > 0) { + // Only check slash_command contexts + const slashLabels = selectedContexts + .filter((c) => c.kind === 'slash_command') + .map((c) => c.label) + .filter(Boolean) as string[] + + for (const label of slashLabels) { + // Check for token at start of text: "/label " + if (slashIndex === 0) { + const startToken = `/${label} ` + if (text.startsWith(startToken)) { + // This slash is part of a completed token + return null + } + } + + // Check for space-wrapped token: " /label " + const token = ` /${label} ` + let fromIndex = 0 + while (fromIndex <= text.length) { + const idx = text.indexOf(token, fromIndex) + if (idx === -1) break + + const tokenStart = idx + const tokenEnd = idx + token.length + const slashPositionInToken = idx + 1 // position of / in " /label " + + if (slashIndex === slashPositionInToken) { + return null + } + + if (pos > tokenStart && pos < tokenEnd) { + return null + } + + fromIndex = tokenEnd + } + } + } + + const segment = before.slice(slashIndex + 1) + // Close the popup if user types space immediately after / + if (segment.length > 0 && /^\s/.test(segment)) { + return null + } + + return { query: segment, start: slashIndex, end: pos } + }, + [message, selectedContexts] + ) + /** * Gets the submenu query text * @@ -200,9 +283,10 @@ export function useMentionMenu({ const before = message.slice(0, active.start) const after = message.slice(active.end) - // Always include leading space, avoid duplicate if one exists - const needsLeadingSpace = !before.endsWith(' ') - const insertion = `${needsLeadingSpace ? ' ' : ''}@${label} ` + // Add leading space only if not at start and previous char isn't whitespace + const needsLeadingSpace = before.length > 0 && !before.endsWith(' ') + // Always add trailing space for easy continued typing + const insertion = `${needsLeadingSpace ? ' ' : ''}@${label} ` const next = `${before}${insertion}${after}` onMessageChange(next) @@ -217,6 +301,41 @@ export function useMentionMenu({ [message, getActiveMentionQueryAtPosition, onMessageChange] ) + /** + * Replaces active slash command with a label + * + * @param label - Label to replace the slash command with + * @returns True if replacement was successful, false if no active slash command found + */ + const replaceActiveSlashWith = useCallback( + (label: string) => { + const textarea = textareaRef.current + if (!textarea) return false + const pos = textarea.selectionStart ?? message.length + const active = getActiveSlashQueryAtPosition(pos) + if (!active) return false + + const before = message.slice(0, active.start) + const after = message.slice(active.end) + + // Add leading space only if not at start and previous char isn't whitespace + const needsLeadingSpace = before.length > 0 && !before.endsWith(' ') + // Always add trailing space for easy continued typing + const insertion = `${needsLeadingSpace ? ' ' : ''}/${label} ` + + const next = `${before}${insertion}${after}` + onMessageChange(next) + + setTimeout(() => { + const cursorPos = before.length + insertion.length + textarea.setSelectionRange(cursorPos, cursorPos) + textarea.focus() + }, 0) + return true + }, + [message, getActiveSlashQueryAtPosition, onMessageChange] + ) + /** * Scrolls active item into view in the menu * @@ -304,10 +423,12 @@ export function useMentionMenu({ // Operations getCaretPos, getActiveMentionQueryAtPosition, + getActiveSlashQueryAtPosition, getSubmenuQuery, resetActiveMentionQuery, insertAtCursor, replaceActiveMentionWith, + replaceActiveSlashWith, scrollActiveItemIntoView, closeMentionMenu, } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts index ca76abe24d..8d21fe83d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts @@ -39,7 +39,7 @@ export function useMentionTokens({ setSelectedContexts, }: UseMentionTokensProps) { /** - * Computes all mention ranges in the message + * Computes all mention ranges in the message (both @mentions and /commands) * * @returns Array of mention ranges sorted by start position */ @@ -55,8 +55,19 @@ export function useMentionTokens({ const uniqueLabels = Array.from(new Set(labels)) for (const label of uniqueLabels) { - // Space-wrapped token: " @label " (search from start) - const token = ` @${label} ` + // Find matching context to determine if it's a slash command + const matchingContext = selectedContexts.find((c) => c.label === label) + const isSlashCommand = matchingContext?.kind === 'slash_command' + const prefix = isSlashCommand ? '/' : '@' + + // Check for token at the very start of the message (no leading space) + const tokenAtStart = `${prefix}${label} ` + if (message.startsWith(tokenAtStart)) { + ranges.push({ start: 0, end: tokenAtStart.length, label }) + } + + // Space-wrapped token: " @label " or " /label " (search from start) + const token = ` ${prefix}${label} ` let fromIndex = 0 while (fromIndex <= message.length) { const idx = message.indexOf(token, fromIndex) 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 b8ad537e66..2d16d1c6f8 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 @@ -21,6 +21,7 @@ import { MentionMenu, ModelSelector, ModeSelector, + 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 { @@ -67,6 +68,8 @@ interface UserInputProps { hideModeSelector?: boolean /** Disable @mention functionality */ disableMentions?: boolean + /** Initial contexts for editing a message with existing context mentions */ + initialContexts?: ChatContext[] } interface UserInputRef { @@ -103,6 +106,7 @@ const UserInput = forwardRef( onModelChangeOverride, hideModeSelector = false, disableMentions = false, + initialContexts, }, ref ) => { @@ -123,6 +127,7 @@ const UserInput = forwardRef( const [isNearTop, setIsNearTop] = useState(false) const [containerRef, setContainerRef] = useState(null) const [inputContainerRef, setInputContainerRef] = useState(null) + const [showSlashMenu, setShowSlashMenu] = useState(false) // Controlled vs uncontrolled message state const message = controlledValue !== undefined ? controlledValue : internalMessage @@ -140,7 +145,7 @@ const UserInput = forwardRef( // Custom hooks - order matters for ref sharing // Context management (manages selectedContexts state) - const contextManagement = useContextManagement({ message }) + const contextManagement = useContextManagement({ message, initialContexts }) // Mention menu const mentionMenu = useMentionMenu({ @@ -370,20 +375,131 @@ const UserInput = forwardRef( } }, [onAbort, isLoading]) + const handleSlashCommandSelect = useCallback( + (command: string) => { + // Capitalize the command for display + const capitalizedCommand = command.charAt(0).toUpperCase() + command.slice(1) + + // Replace the active slash query with the capitalized command + mentionMenu.replaceActiveSlashWith(capitalizedCommand) + + // Add as a context so it gets highlighted + contextManagement.addContext({ + kind: 'slash_command', + command, + label: capitalizedCommand, + }) + + setShowSlashMenu(false) + mentionMenu.textareaRef.current?.focus() + }, + [mentionMenu, contextManagement] + ) + const handleKeyDown = useCallback( (e: KeyboardEvent) => { // Escape key handling - if (e.key === 'Escape' && mentionMenu.showMentionMenu) { + if (e.key === 'Escape' && (mentionMenu.showMentionMenu || showSlashMenu)) { e.preventDefault() if (mentionMenu.openSubmenuFor) { mentionMenu.setOpenSubmenuFor(null) mentionMenu.setSubmenuQueryStart(null) } else { mentionMenu.closeMentionMenu() + setShowSlashMenu(false) } return } + // Arrow navigation in slash menu + if (showSlashMenu) { + const TOP_LEVEL_COMMANDS = ['fast', 'plan', 'debug', 'research', 'deploy', 'superagent'] + const WEB_COMMANDS = ['search', 'read', 'scrape', 'crawl'] + const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS] + + const caretPos = mentionMenu.getCaretPos() + const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message) + const query = activeSlash?.query.trim().toLowerCase() || '' + const showAggregatedView = query.length > 0 + + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault() + + if (mentionMenu.openSubmenuFor === 'Web') { + // Navigate in Web submenu + const last = WEB_COMMANDS.length - 1 + mentionMenu.setSubmenuActiveIndex((prev) => { + const next = + e.key === 'ArrowDown' + ? prev >= last + ? 0 + : prev + 1 + : prev <= 0 + ? last + : prev - 1 + requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next)) + return next + }) + } else if (showAggregatedView) { + // Navigate in filtered view + const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query)) + const last = Math.max(0, filtered.length - 1) + mentionMenu.setSubmenuActiveIndex((prev) => { + if (filtered.length === 0) return 0 + const next = + e.key === 'ArrowDown' + ? prev >= last + ? 0 + : prev + 1 + : prev <= 0 + ? last + : prev - 1 + requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next)) + return next + }) + } else { + // Navigate in folder view (top-level + Web folder) + const totalItems = TOP_LEVEL_COMMANDS.length + 1 // +1 for Web folder + const last = totalItems - 1 + mentionMenu.setMentionActiveIndex((prev) => { + const next = + e.key === 'ArrowDown' + ? prev >= last + ? 0 + : prev + 1 + : prev <= 0 + ? last + : prev - 1 + requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next)) + return next + }) + } + return + } + + // Arrow right to enter Web submenu + if (e.key === 'ArrowRight') { + e.preventDefault() + if (!showAggregatedView && !mentionMenu.openSubmenuFor) { + // Check if Web folder is selected (it's after all top-level commands) + if (mentionMenu.mentionActiveIndex === TOP_LEVEL_COMMANDS.length) { + mentionMenu.setOpenSubmenuFor('Web') + mentionMenu.setSubmenuActiveIndex(0) + } + } + return + } + + // Arrow left to exit submenu + if (e.key === 'ArrowLeft') { + e.preventDefault() + if (mentionMenu.openSubmenuFor) { + mentionMenu.setOpenSubmenuFor(null) + } + return + } + } + // Arrow navigation in mention menu if (mentionKeyboard.handleArrowNavigation(e)) return if (mentionKeyboard.handleArrowRight(e)) return @@ -392,6 +508,42 @@ const UserInput = forwardRef( // Enter key handling if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault() + if (showSlashMenu) { + const TOP_LEVEL_COMMANDS = ['fast', 'plan', 'debug', 'research', 'deploy', 'superagent'] + const WEB_COMMANDS = ['search', 'read', 'scrape', 'crawl'] + const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS] + + const caretPos = mentionMenu.getCaretPos() + const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message) + const query = activeSlash?.query.trim().toLowerCase() || '' + const showAggregatedView = query.length > 0 + + if (mentionMenu.openSubmenuFor === 'Web') { + // Select from Web submenu + const selectedCommand = + WEB_COMMANDS[mentionMenu.submenuActiveIndex] || WEB_COMMANDS[0] + handleSlashCommandSelect(selectedCommand) + } else if (showAggregatedView) { + // Select from filtered view + const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query)) + if (filtered.length > 0) { + const selectedCommand = filtered[mentionMenu.submenuActiveIndex] || filtered[0] + handleSlashCommandSelect(selectedCommand) + } + } else { + // Folder navigation view + const selectedIndex = mentionMenu.mentionActiveIndex + if (selectedIndex < TOP_LEVEL_COMMANDS.length) { + // Top-level command selected + handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex]) + } else if (selectedIndex === TOP_LEVEL_COMMANDS.length) { + // Web folder selected - open it + mentionMenu.setOpenSubmenuFor('Web') + mentionMenu.setSubmenuActiveIndex(0) + } + } + return + } if (!mentionMenu.showMentionMenu) { handleSubmit() } else { @@ -469,7 +621,15 @@ const UserInput = forwardRef( } } }, - [mentionMenu, mentionKeyboard, handleSubmit, message.length, mentionTokensWithContext] + [ + mentionMenu, + mentionKeyboard, + handleSubmit, + handleSlashCommandSelect, + message, + mentionTokensWithContext, + showSlashMenu, + ] ) const handleInputChange = useCallback( @@ -481,9 +641,14 @@ const UserInput = forwardRef( if (disableMentions) return const caret = e.target.selectionStart ?? newValue.length - const active = mentionMenu.getActiveMentionQueryAtPosition(caret, newValue) - if (active) { + // Check for @ mention trigger + const activeMention = mentionMenu.getActiveMentionQueryAtPosition(caret, newValue) + // Check for / slash command trigger + const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caret, newValue) + + if (activeMention) { + setShowSlashMenu(false) mentionMenu.setShowMentionMenu(true) mentionMenu.setInAggregated(false) if (mentionMenu.openSubmenuFor) { @@ -492,10 +657,17 @@ const UserInput = forwardRef( mentionMenu.setMentionActiveIndex(0) mentionMenu.setSubmenuActiveIndex(0) } + } else if (activeSlash) { + mentionMenu.setShowMentionMenu(false) + mentionMenu.setOpenSubmenuFor(null) + mentionMenu.setSubmenuQueryStart(null) + setShowSlashMenu(true) + mentionMenu.setSubmenuActiveIndex(0) } else { mentionMenu.setShowMentionMenu(false) mentionMenu.setOpenSubmenuFor(null) mentionMenu.setSubmenuQueryStart(null) + setShowSlashMenu(false) } }, [setMessage, mentionMenu, disableMentions] @@ -542,6 +714,32 @@ const UserInput = forwardRef( mentionMenu.setSubmenuActiveIndex(0) }, [disabled, isLoading, mentionMenu, message, setMessage]) + const handleOpenSlashMenu = useCallback(() => { + if (disabled || isLoading) return + const textarea = mentionMenu.textareaRef.current + if (!textarea) return + textarea.focus() + const pos = textarea.selectionStart ?? message.length + const needsSpaceBefore = pos > 0 && !/\s/.test(message.charAt(pos - 1)) + + const insertText = needsSpaceBefore ? ' /' : '/' + const start = textarea.selectionStart ?? message.length + const end = textarea.selectionEnd ?? message.length + const before = message.slice(0, start) + const after = message.slice(end) + const next = `${before}${insertText}${after}` + setMessage(next) + + setTimeout(() => { + const newPos = before.length + insertText.length + textarea.setSelectionRange(newPos, newPos) + textarea.focus() + }, 0) + + setShowSlashMenu(true) + mentionMenu.setSubmenuActiveIndex(0) + }, [disabled, isLoading, mentionMenu, message, setMessage]) + const canSubmit = message.trim().length > 0 && !disabled && !isLoading const showAbortButton = isLoading && onAbort @@ -643,6 +841,20 @@ const UserInput = forwardRef( + + + / + + + {/* Selected Context Pills */} ( />, document.body )} + + {/* Slash Menu Portal */} + {!disableMentions && + showSlashMenu && + createPortal( + , + document.body + )}
    {/* Bottom Row: Mode Selector + Model Selector + Attach Button + Send Button */} diff --git a/apps/sim/lib/copilot/api.ts b/apps/sim/lib/copilot/api.ts index 581fe0511f..f45cd78660 100644 --- a/apps/sim/lib/copilot/api.ts +++ b/apps/sim/lib/copilot/api.ts @@ -99,6 +99,7 @@ export interface SendMessageRequest { workflowId?: string executionId?: string }> + commands?: string[] } /** diff --git a/apps/sim/lib/copilot/tools/client/blocks/get-block-config.ts b/apps/sim/lib/copilot/tools/client/blocks/get-block-config.ts index 6b3a15c531..be4196c443 100644 --- a/apps/sim/lib/copilot/tools/client/blocks/get-block-config.ts +++ b/apps/sim/lib/copilot/tools/client/blocks/get-block-config.ts @@ -10,6 +10,7 @@ import { GetBlockConfigInput, GetBlockConfigResult, } from '@/lib/copilot/tools/shared/schemas' +import { getBlock } from '@/blocks/registry' interface GetBlockConfigArgs { blockType: string @@ -39,7 +40,9 @@ export class GetBlockConfigClientTool extends BaseClientTool { }, getDynamicText: (params, state) => { if (params?.blockType && typeof params.blockType === 'string') { - const blockName = params.blockType.replace(/_/g, ' ') + // Look up the block config to get the human-readable name + const blockConfig = getBlock(params.blockType) + const blockName = (blockConfig?.name ?? params.blockType.replace(/_/g, ' ')).toLowerCase() const opSuffix = params.operation ? ` (${params.operation})` : '' switch (state) { diff --git a/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts b/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts index 41cd7bd8f6..a104688e5f 100644 --- a/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts +++ b/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts @@ -10,6 +10,7 @@ import { GetBlockOptionsInput, GetBlockOptionsResult, } from '@/lib/copilot/tools/shared/schemas' +import { getBlock } from '@/blocks/registry' interface GetBlockOptionsArgs { blockId: string @@ -37,7 +38,9 @@ export class GetBlockOptionsClientTool extends BaseClientTool { }, getDynamicText: (params, state) => { if (params?.blockId && typeof params.blockId === 'string') { - const blockName = params.blockId.replace(/_/g, ' ') + // Look up the block config to get the human-readable name + const blockConfig = getBlock(params.blockId) + const blockName = (blockConfig?.name ?? params.blockId.replace(/_/g, ' ')).toLowerCase() switch (state) { case ClientToolCallState.success: diff --git a/apps/sim/lib/copilot/tools/client/init-tool-configs.ts b/apps/sim/lib/copilot/tools/client/init-tool-configs.ts index 821e5ec8d6..b2d480f037 100644 --- a/apps/sim/lib/copilot/tools/client/init-tool-configs.ts +++ b/apps/sim/lib/copilot/tools/client/init-tool-configs.ts @@ -18,6 +18,7 @@ import './other/make-api-request' import './other/plan' import './other/research' import './other/sleep' +import './other/superagent' import './other/test' import './other/tour' import './other/workflow' diff --git a/apps/sim/lib/copilot/tools/client/other/crawl-website.ts b/apps/sim/lib/copilot/tools/client/other/crawl-website.ts new file mode 100644 index 0000000000..5fee1690dd --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/other/crawl-website.ts @@ -0,0 +1,53 @@ +import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react' +import { + BaseClientTool, + type BaseClientToolMetadata, + ClientToolCallState, +} from '@/lib/copilot/tools/client/base-tool' + +export class CrawlWebsiteClientTool extends BaseClientTool { + static readonly id = 'crawl_website' + + constructor(toolCallId: string) { + super(toolCallId, CrawlWebsiteClientTool.id, CrawlWebsiteClientTool.metadata) + } + + static readonly metadata: BaseClientToolMetadata = { + displayNames: { + [ClientToolCallState.generating]: { text: 'Crawling website', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Crawling website', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Crawling website', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Crawled website', icon: Globe }, + [ClientToolCallState.error]: { text: 'Failed to crawl website', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted crawling website', icon: MinusCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped crawling website', icon: MinusCircle }, + }, + interrupt: undefined, + getDynamicText: (params, state) => { + if (params?.url && typeof params.url === 'string') { + const url = params.url + const truncated = url.length > 50 ? `${url.slice(0, 50)}...` : url + + switch (state) { + case ClientToolCallState.success: + return `Crawled ${truncated}` + case ClientToolCallState.executing: + case ClientToolCallState.generating: + case ClientToolCallState.pending: + return `Crawling ${truncated}` + case ClientToolCallState.error: + return `Failed to crawl ${truncated}` + case ClientToolCallState.aborted: + return `Aborted crawling ${truncated}` + case ClientToolCallState.rejected: + return `Skipped crawling ${truncated}` + } + } + return undefined + }, + } + + async execute(): Promise { + return + } +} diff --git a/apps/sim/lib/copilot/tools/client/other/get-page-contents.ts b/apps/sim/lib/copilot/tools/client/other/get-page-contents.ts new file mode 100644 index 0000000000..a5ffa6eeb2 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/other/get-page-contents.ts @@ -0,0 +1,54 @@ +import { FileText, Loader2, MinusCircle, XCircle } from 'lucide-react' +import { + BaseClientTool, + type BaseClientToolMetadata, + ClientToolCallState, +} from '@/lib/copilot/tools/client/base-tool' + +export class GetPageContentsClientTool extends BaseClientTool { + static readonly id = 'get_page_contents' + + constructor(toolCallId: string) { + super(toolCallId, GetPageContentsClientTool.id, GetPageContentsClientTool.metadata) + } + + static readonly metadata: BaseClientToolMetadata = { + displayNames: { + [ClientToolCallState.generating]: { text: 'Getting page contents', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Getting page contents', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Getting page contents', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Retrieved page contents', icon: FileText }, + [ClientToolCallState.error]: { text: 'Failed to get page contents', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted getting page contents', icon: MinusCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped getting page contents', icon: MinusCircle }, + }, + interrupt: undefined, + getDynamicText: (params, state) => { + if (params?.urls && Array.isArray(params.urls) && params.urls.length > 0) { + const firstUrl = String(params.urls[0]) + const truncated = firstUrl.length > 40 ? `${firstUrl.slice(0, 40)}...` : firstUrl + const count = params.urls.length + + switch (state) { + case ClientToolCallState.success: + return count > 1 ? `Retrieved ${count} pages` : `Retrieved ${truncated}` + case ClientToolCallState.executing: + case ClientToolCallState.generating: + case ClientToolCallState.pending: + return count > 1 ? `Getting ${count} pages` : `Getting ${truncated}` + case ClientToolCallState.error: + return count > 1 ? `Failed to get ${count} pages` : `Failed to get ${truncated}` + case ClientToolCallState.aborted: + return count > 1 ? `Aborted getting ${count} pages` : `Aborted getting ${truncated}` + case ClientToolCallState.rejected: + return count > 1 ? `Skipped getting ${count} pages` : `Skipped getting ${truncated}` + } + } + return undefined + }, + } + + async execute(): Promise { + return + } +} diff --git a/apps/sim/lib/copilot/tools/client/other/scrape-page.ts b/apps/sim/lib/copilot/tools/client/other/scrape-page.ts new file mode 100644 index 0000000000..0bb5f72a7e --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/other/scrape-page.ts @@ -0,0 +1,53 @@ +import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react' +import { + BaseClientTool, + type BaseClientToolMetadata, + ClientToolCallState, +} from '@/lib/copilot/tools/client/base-tool' + +export class ScrapePageClientTool extends BaseClientTool { + static readonly id = 'scrape_page' + + constructor(toolCallId: string) { + super(toolCallId, ScrapePageClientTool.id, ScrapePageClientTool.metadata) + } + + static readonly metadata: BaseClientToolMetadata = { + displayNames: { + [ClientToolCallState.generating]: { text: 'Scraping page', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Scraping page', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Scraping page', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Scraped page', icon: Globe }, + [ClientToolCallState.error]: { text: 'Failed to scrape page', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted scraping page', icon: MinusCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped scraping page', icon: MinusCircle }, + }, + interrupt: undefined, + getDynamicText: (params, state) => { + if (params?.url && typeof params.url === 'string') { + const url = params.url + const truncated = url.length > 50 ? `${url.slice(0, 50)}...` : url + + switch (state) { + case ClientToolCallState.success: + return `Scraped ${truncated}` + case ClientToolCallState.executing: + case ClientToolCallState.generating: + case ClientToolCallState.pending: + return `Scraping ${truncated}` + case ClientToolCallState.error: + return `Failed to scrape ${truncated}` + case ClientToolCallState.aborted: + return `Aborted scraping ${truncated}` + case ClientToolCallState.rejected: + return `Skipped scraping ${truncated}` + } + } + return undefined + }, + } + + async execute(): Promise { + return + } +} diff --git a/apps/sim/lib/copilot/tools/client/other/search-online.ts b/apps/sim/lib/copilot/tools/client/other/search-online.ts index f5022c3f44..fd96c5cc99 100644 --- a/apps/sim/lib/copilot/tools/client/other/search-online.ts +++ b/apps/sim/lib/copilot/tools/client/other/search-online.ts @@ -1,19 +1,9 @@ -import { createLogger } from '@sim/logger' import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas' - -interface SearchOnlineArgs { - query: string - num?: number - type?: string - gl?: string - hl?: string -} export class SearchOnlineClientTool extends BaseClientTool { static readonly id = 'search_online' @@ -32,6 +22,7 @@ export class SearchOnlineClientTool extends BaseClientTool { [ClientToolCallState.rejected]: { text: 'Skipped online search', icon: MinusCircle }, [ClientToolCallState.aborted]: { text: 'Aborted online search', icon: XCircle }, }, + interrupt: undefined, getDynamicText: (params, state) => { if (params?.query && typeof params.query === 'string') { const query = params.query @@ -56,28 +47,7 @@ export class SearchOnlineClientTool extends BaseClientTool { }, } - async execute(args?: SearchOnlineArgs): Promise { - const logger = createLogger('SearchOnlineClientTool') - try { - this.setState(ClientToolCallState.executing) - const res = await fetch('/api/copilot/execute-copilot-server-tool', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ toolName: 'search_online', payload: args || {} }), - }) - if (!res.ok) { - const txt = await res.text().catch(() => '') - throw new Error(txt || `Server error (${res.status})`) - } - const json = await res.json() - const parsed = ExecuteResponseSuccessSchema.parse(json) - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, 'Online search complete', parsed.result) - this.setState(ClientToolCallState.success) - } catch (e: any) { - logger.error('execute failed', { message: e?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, e?.message || 'Search failed') - } + async execute(): Promise { + return } } diff --git a/apps/sim/lib/copilot/tools/client/other/superagent.ts b/apps/sim/lib/copilot/tools/client/other/superagent.ts new file mode 100644 index 0000000000..99ec1fbfe1 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/other/superagent.ts @@ -0,0 +1,56 @@ +import { Loader2, Sparkles, XCircle } from 'lucide-react' +import { + BaseClientTool, + type BaseClientToolMetadata, + ClientToolCallState, +} from '@/lib/copilot/tools/client/base-tool' +import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config' + +interface SuperagentArgs { + instruction: string +} + +/** + * Superagent tool that spawns a powerful subagent for complex tasks. + * This tool auto-executes and the actual work is done by the superagent. + * The subagent's output is streamed as nested content under this tool call. + */ +export class SuperagentClientTool extends BaseClientTool { + static readonly id = 'superagent' + + constructor(toolCallId: string) { + super(toolCallId, SuperagentClientTool.id, SuperagentClientTool.metadata) + } + + static readonly metadata: BaseClientToolMetadata = { + displayNames: { + [ClientToolCallState.generating]: { text: 'Superagent working', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Superagent working', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Superagent working', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Superagent completed', icon: Sparkles }, + [ClientToolCallState.error]: { text: 'Superagent failed', icon: XCircle }, + [ClientToolCallState.rejected]: { text: 'Superagent skipped', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Superagent aborted', icon: XCircle }, + }, + uiConfig: { + subagent: { + streamingLabel: 'Superagent working', + completedLabel: 'Superagent completed', + shouldCollapse: true, + outputArtifacts: [], + }, + }, + } + + /** + * Execute the superagent tool. + * This just marks the tool as executing - the actual work is done server-side + * by the superagent, and its output is streamed as subagent events. + */ + async execute(_args?: SuperagentArgs): Promise { + this.setState(ClientToolCallState.executing) + } +} + +// Register UI config at module load +registerToolUIConfig(SuperagentClientTool.id, SuperagentClientTool.metadata.uiConfig!) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 33317ba1cf..5e7f26d0d0 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -1,7 +1,7 @@ /** * Environment utility functions for consistent environment detection across the application */ -import { env, isFalsy, isTruthy } from './env' +import { env, getEnv, isFalsy, isTruthy } from './env' /** * Is the application running in production mode @@ -21,7 +21,9 @@ export const isTest = env.NODE_ENV === 'test' /** * Is this the hosted version of the application */ -export const isHosted = true +export const isHosted = + getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' || + getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai' /** * Is billing enforcement enabled diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index 97b785177a..64d1d3e7bf 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -27,11 +27,13 @@ import { import { NavigateUIClientTool } from '@/lib/copilot/tools/client/navigation/navigate-ui' import { AuthClientTool } from '@/lib/copilot/tools/client/other/auth' import { CheckoffTodoClientTool } from '@/lib/copilot/tools/client/other/checkoff-todo' +import { CrawlWebsiteClientTool } from '@/lib/copilot/tools/client/other/crawl-website' import { CustomToolClientTool } from '@/lib/copilot/tools/client/other/custom-tool' import { DebugClientTool } from '@/lib/copilot/tools/client/other/debug' import { DeployClientTool } from '@/lib/copilot/tools/client/other/deploy' import { EditClientTool } from '@/lib/copilot/tools/client/other/edit' import { EvaluateClientTool } from '@/lib/copilot/tools/client/other/evaluate' +import { GetPageContentsClientTool } from '@/lib/copilot/tools/client/other/get-page-contents' import { InfoClientTool } from '@/lib/copilot/tools/client/other/info' import { KnowledgeClientTool } from '@/lib/copilot/tools/client/other/knowledge' import { MakeApiRequestClientTool } from '@/lib/copilot/tools/client/other/make-api-request' @@ -40,6 +42,7 @@ import { OAuthRequestAccessClientTool } from '@/lib/copilot/tools/client/other/o import { PlanClientTool } from '@/lib/copilot/tools/client/other/plan' import { RememberDebugClientTool } from '@/lib/copilot/tools/client/other/remember-debug' import { ResearchClientTool } from '@/lib/copilot/tools/client/other/research' +import { ScrapePageClientTool } from '@/lib/copilot/tools/client/other/scrape-page' import { SearchDocumentationClientTool } from '@/lib/copilot/tools/client/other/search-documentation' import { SearchErrorsClientTool } from '@/lib/copilot/tools/client/other/search-errors' import { SearchLibraryDocsClientTool } from '@/lib/copilot/tools/client/other/search-library-docs' @@ -120,6 +123,9 @@ const CLIENT_TOOL_INSTANTIATORS: Record any> = { search_library_docs: (id) => new SearchLibraryDocsClientTool(id), search_patterns: (id) => new SearchPatternsClientTool(id), search_errors: (id) => new SearchErrorsClientTool(id), + scrape_page: (id) => new ScrapePageClientTool(id), + get_page_contents: (id) => new GetPageContentsClientTool(id), + crawl_website: (id) => new CrawlWebsiteClientTool(id), remember_debug: (id) => new RememberDebugClientTool(id), set_environment_variables: (id) => new SetEnvironmentVariablesClientTool(id), get_credentials: (id) => new GetCredentialsClientTool(id), @@ -179,6 +185,9 @@ export const CLASS_TOOL_METADATA: Record = { } } catch {} - // Integration tools: Check if auto-allowed, otherwise wait for user confirmation - // This handles tools like google_calendar_*, exa_*, etc. that aren't in the client registry + // Integration tools: Stay in pending state until user confirms via buttons + // This handles tools like google_calendar_*, exa_*, gmail_read, etc. that aren't in the client registry // Only relevant if mode is 'build' (agent) - const { mode, workflowId, autoAllowedTools } = get() + const { mode, workflowId } = get() if (mode === 'build' && workflowId) { - // Check if tool was NOT found in client registry (def is undefined from above) + // Check if tool was NOT found in client registry const def = name ? getTool(name) : undefined const inst = getClientTool(id) as any if (!def && !inst && name) { - // Check if this tool is auto-allowed - if (autoAllowedTools.includes(name)) { - logger.info('[build mode] Integration tool auto-allowed, executing', { id, name }) - - // Auto-execute the tool - setTimeout(() => { - get().executeIntegrationTool(id) - }, 0) - } else { - // Integration tools stay in pending state until user confirms - logger.info('[build mode] Integration tool awaiting user confirmation', { - id, - name, - }) - } + // Integration tools stay in pending state until user confirms + logger.info('[build mode] Integration tool awaiting user confirmation', { + id, + name, + }) } } }, @@ -1854,7 +1853,7 @@ const subAgentSSEHandlers: Record = { updateToolCallWithSubAgentData(context, get, set, parentToolCallId) - // Execute client tools (same logic as main tool_call handler) + // Execute client tools in parallel (non-blocking) - same pattern as main tool_call handler try { const def = getTool(name) if (def) { @@ -1863,29 +1862,33 @@ const subAgentSSEHandlers: Record = { ? !!def.hasInterrupt(args || {}) : !!def.hasInterrupt if (!hasInterrupt) { - // Auto-execute tools without interrupts + // Auto-execute tools without interrupts - non-blocking const ctx = createExecutionContext({ toolCallId: id, toolName: name }) - try { - await def.execute(ctx, args || {}) - } catch (execErr: any) { - logger.error('[SubAgent] Tool execution failed', { id, name, error: execErr?.message }) - } + Promise.resolve() + .then(() => def.execute(ctx, args || {})) + .catch((execErr: any) => { + logger.error('[SubAgent] Tool execution failed', { + id, + name, + error: execErr?.message, + }) + }) } } else { - // Fallback to class-based tools + // Fallback to class-based tools - non-blocking const instance = getClientTool(id) if (instance) { const hasInterruptDisplays = !!instance.getInterruptDisplays?.() if (!hasInterruptDisplays) { - try { - await instance.execute(args || {}) - } catch (execErr: any) { - logger.error('[SubAgent] Class tool execution failed', { - id, - name, - error: execErr?.message, + Promise.resolve() + .then(() => instance.execute(args || {})) + .catch((execErr: any) => { + logger.error('[SubAgent] Class tool execution failed', { + id, + name, + error: execErr?.message, + }) }) - } } } } @@ -2515,6 +2518,13 @@ export const useCopilotStore = create()( // Call copilot API const apiMode: 'ask' | 'agent' | 'plan' = mode === 'ask' ? 'ask' : mode === 'plan' ? 'plan' : 'agent' + + // Extract slash commands from contexts (lowercase) and filter them out from contexts + const commands = contexts + ?.filter((c) => c.kind === 'slash_command' && 'command' in c) + .map((c) => (c as any).command.toLowerCase()) as string[] | undefined + const filteredContexts = contexts?.filter((c) => c.kind !== 'slash_command') + const result = await sendStreamingMessage({ message: messageToSend, userMessageId: userMessage.id, @@ -2526,7 +2536,8 @@ export const useCopilotStore = create()( createNewChat: !currentChat, stream, fileAttachments, - contexts, + contexts: filteredContexts, + commands: commands?.length ? commands : undefined, abortSignal: abortController.signal, }) @@ -2618,13 +2629,14 @@ export const useCopilotStore = create()( ), isSendingMessage: false, isAborting: false, - abortController: null, + // Keep abortController so streaming loop can check signal.aborted + // It will be nulled when streaming completes or new message starts })) } else { set({ isSendingMessage: false, isAborting: false, - abortController: null, + // Keep abortController so streaming loop can check signal.aborted }) } @@ -2653,7 +2665,7 @@ export const useCopilotStore = create()( } catch {} } } catch { - set({ isSendingMessage: false, isAborting: false, abortController: null }) + set({ isSendingMessage: false, isAborting: false }) } }, @@ -3154,6 +3166,7 @@ export const useCopilotStore = create()( : msg ), isSendingMessage: false, + isAborting: false, abortController: null, currentUserMessageId: null, })) diff --git a/apps/sim/stores/panel/copilot/types.ts b/apps/sim/stores/panel/copilot/types.ts index fbb6404aac..0ddb9515f8 100644 --- a/apps/sim/stores/panel/copilot/types.ts +++ b/apps/sim/stores/panel/copilot/types.ts @@ -85,6 +85,7 @@ export type ChatContext = | { kind: 'knowledge'; knowledgeId?: string; label: string } | { kind: 'templates'; templateId?: string; label: string } | { kind: 'docs'; label: string } + | { kind: 'slash_command'; command: string; label: string } import type { CopilotChat as ApiCopilotChat } from '@/lib/copilot/api'