-
+
{isJsonObject ? (
{JSON.stringify(message.content, null, 2)}
) : (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx
index 99f8fdedcf..8d04e43d55 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx
@@ -1,7 +1,7 @@
'use client'
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { ArrowUp } from 'lucide-react'
+import { ArrowDown, ArrowUp } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -42,6 +42,7 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
} = useChatStore()
const { entries } = useConsoleStore()
const messagesEndRef = useRef
(null)
+ const scrollAreaRef = useRef(null)
const inputRef = useRef(null)
const timeoutRef = useRef(null)
const abortControllerRef = useRef(null)
@@ -50,6 +51,10 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
const [promptHistory, setPromptHistory] = useState([])
const [historyIndex, setHistoryIndex] = useState(-1)
+ // Scroll state
+ const [isNearBottom, setIsNearBottom] = useState(true)
+ const [showScrollButton, setShowScrollButton] = useState(false)
+
// Use the execution store state to track if a workflow is executing
const { isExecuting } = useExecutionStore()
@@ -125,6 +130,31 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
}, delay)
}, [])
+ // Scroll to bottom function
+ const scrollToBottom = useCallback(() => {
+ if (messagesEndRef.current) {
+ messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
+ }
+ }, [])
+
+ // Handle scroll events to track user position
+ const handleScroll = useCallback(() => {
+ const scrollArea = scrollAreaRef.current
+ if (!scrollArea) return
+
+ // Find the viewport element inside the ScrollArea
+ const viewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]')
+ if (!viewport) return
+
+ const { scrollTop, scrollHeight, clientHeight } = viewport
+ const distanceFromBottom = scrollHeight - scrollTop - clientHeight
+
+ // Consider "near bottom" if within 100px of bottom
+ const nearBottom = distanceFromBottom <= 100
+ setIsNearBottom(nearBottom)
+ setShowScrollButton(!nearBottom)
+ }, [])
+
// Cleanup on unmount
useEffect(() => {
return () => {
@@ -137,12 +167,47 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
}
}, [])
- // Auto-scroll to bottom when new messages are added
+ // Attach scroll listener
useEffect(() => {
- if (messagesEndRef.current) {
- messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
+ const scrollArea = scrollAreaRef.current
+ if (!scrollArea) return
+
+ // Find the viewport element inside the ScrollArea
+ const viewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]')
+ if (!viewport) return
+
+ viewport.addEventListener('scroll', handleScroll, { passive: true })
+
+ // Also listen for scrollend event if available (for smooth scrolling)
+ if ('onscrollend' in viewport) {
+ viewport.addEventListener('scrollend', handleScroll, { passive: true })
}
- }, [workflowMessages])
+
+ // Initial scroll state check with small delay to ensure DOM is ready
+ setTimeout(handleScroll, 100)
+
+ return () => {
+ viewport.removeEventListener('scroll', handleScroll)
+ if ('onscrollend' in viewport) {
+ viewport.removeEventListener('scrollend', handleScroll)
+ }
+ }
+ }, [handleScroll])
+
+ // Auto-scroll to bottom when new messages are added, but only if user is near bottom
+ // Exception: Always scroll when sending a new message
+ useEffect(() => {
+ if (workflowMessages.length === 0) return
+
+ const lastMessage = workflowMessages[workflowMessages.length - 1]
+ const isNewUserMessage = lastMessage?.type === 'user'
+
+ // Always scroll for new user messages, or only if near bottom for assistant messages
+ if ((isNewUserMessage || isNearBottom) && messagesEndRef.current) {
+ messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
+ // Let the scroll event handler update the state naturally after animation completes
+ }
+ }, [workflowMessages, isNearBottom])
// Handle send message
const handleSendMessage = useCallback(async () => {
@@ -449,7 +514,7 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
No messages yet
) : (
-
+
{workflowMessages.map((message) => (
@@ -458,6 +523,21 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
)}
+
+ {/* Scroll to bottom button */}
+ {showScrollButton && (
+
+
+
+ )}
{/* Input section - Fixed height */}
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
new file mode 100644
index 0000000000..13e7d52a7a
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx
@@ -0,0 +1,361 @@
+'use client'
+
+import React, { useEffect, useMemo, useState } from 'react'
+import { Check, Copy } from 'lucide-react'
+import ReactMarkdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+
+const getTextContent = (element: React.ReactNode): string => {
+ if (typeof element === 'string') {
+ return element
+ }
+ if (typeof element === 'number') {
+ return String(element)
+ }
+ if (React.isValidElement(element)) {
+ const elementProps = element.props as { children?: React.ReactNode }
+ return getTextContent(elementProps.children)
+ }
+ if (Array.isArray(element)) {
+ return element.map(getTextContent).join('')
+ }
+ return ''
+}
+
+// Fix for code block text rendering issues
+if (typeof document !== 'undefined') {
+ const styleId = 'copilot-markdown-fix'
+ if (!document.getElementById(styleId)) {
+ const style = document.createElement('style')
+ style.id = styleId
+ style.textContent = `
+ .copilot-markdown-wrapper pre {
+ color: #e5e7eb !important;
+ font-weight: 400 !important;
+ text-shadow: none !important;
+ filter: none !important;
+ opacity: 1 !important;
+ -webkit-font-smoothing: antialiased !important;
+ -moz-osx-font-smoothing: grayscale !important;
+ text-rendering: optimizeLegibility !important;
+ }
+
+ .dark .copilot-markdown-wrapper pre {
+ color: #f3f4f6 !important;
+ }
+
+ .copilot-markdown-wrapper pre code,
+ .copilot-markdown-wrapper pre code *,
+ .copilot-markdown-wrapper pre span,
+ .copilot-markdown-wrapper pre div {
+ color: inherit !important;
+ opacity: 1 !important;
+ font-weight: 400 !important;
+ text-shadow: none !important;
+ filter: none !important;
+ -webkit-font-smoothing: antialiased !important;
+ -moz-osx-font-smoothing: grayscale !important;
+ text-rendering: optimizeLegibility !important;
+ }
+ `
+ document.head.appendChild(style)
+ }
+}
+
+// Link component with preview
+function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+
+ {href}
+
+
+ )
+}
+
+interface CopilotMarkdownRendererProps {
+ content: string
+}
+
+export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRendererProps) {
+ const [copiedCodeBlocks, setCopiedCodeBlocks] = useState
>({})
+
+ // Reset copy success state after 2 seconds
+ 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)
+ }
+ }, [copiedCodeBlocks])
+
+ // Custom components for react-markdown with current styling - memoized to prevent re-renders
+ const markdownComponents = useMemo(
+ () => ({
+ // Paragraph
+ p: ({ children }: React.HTMLAttributes) => (
+
+ {children}
+
+ ),
+
+ // Headings
+ h1: ({ children }: React.HTMLAttributes) => (
+
+ {children}
+
+ ),
+ h2: ({ children }: React.HTMLAttributes) => (
+
+ {children}
+
+ ),
+ h3: ({ children }: React.HTMLAttributes) => (
+
+ {children}
+
+ ),
+ h4: ({ children }: React.HTMLAttributes) => (
+
+ {children}
+
+ ),
+
+ // Lists
+ ul: ({ children }: React.HTMLAttributes) => (
+
+ ),
+ ol: ({ children }: React.HTMLAttributes) => (
+
+ {children}
+
+ ),
+ li: ({
+ children,
+ ordered,
+ }: React.LiHTMLAttributes & { ordered?: boolean }) => (
+
+ {children}
+
+ ),
+
+ // Code blocks
+ 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'
+ }
+
+ // Extract actual text content
+ let actualCodeText = ''
+ if (typeof codeContent === 'string') {
+ actualCodeText = codeContent
+ } else if (React.isValidElement(codeContent)) {
+ // If it's a React element, try to get its text content
+ actualCodeText = getTextContent(codeContent)
+ } else if (Array.isArray(codeContent)) {
+ // If it's an array of elements, join their text content
+ actualCodeText = codeContent
+ .map((child) =>
+ typeof child === 'string'
+ ? child
+ : React.isValidElement(child)
+ ? getTextContent(child)
+ : ''
+ )
+ .join('')
+ } else {
+ actualCodeText = String(codeContent || '')
+ }
+
+ // Create a unique key for this code block based on content
+ 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 }))
+ }
+ }
+
+ return (
+
+
+ {language}
+
+
+
+
+ )
+ },
+
+ // Inline code
+ code: ({
+ inline,
+ className,
+ children,
+ ...props
+ }: React.HTMLAttributes & { className?: string; inline?: boolean }) => {
+ if (inline) {
+ return (
+
+ {children}
+
+ )
+ }
+ return (
+
+ {children}
+
+ )
+ },
+
+ // Bold text
+ strong: ({ children }: React.HTMLAttributes) => (
+ {children}
+ ),
+
+ // Bold text (alternative)
+ b: ({ children }: React.HTMLAttributes) => (
+ {children}
+ ),
+
+ // Italic text
+ em: ({ children }: React.HTMLAttributes) => (
+ {children}
+ ),
+
+ // Italic text (alternative)
+ i: ({ children }: React.HTMLAttributes) => (
+ {children}
+ ),
+
+ // Blockquotes
+ blockquote: ({ children }: React.HTMLAttributes) => (
+
+ {children}
+
+ ),
+
+ // Horizontal rule
+ hr: () =>
,
+
+ // Links
+ a: ({ href, children, ...props }: React.AnchorHTMLAttributes) => (
+
+ {children}
+
+ ),
+
+ // Tables
+ table: ({ children }: React.TableHTMLAttributes) => (
+
+ ),
+ 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) => (
+
+ ),
+ }),
+ [copiedCodeBlocks]
+ )
+
+ return (
+
+
+ {content}
+
+
+ )
+}
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 d92f418f36..bae6d96a34 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
@@ -1,42 +1,21 @@
'use client'
-import React, { type FC, memo, useEffect, useMemo, useRef, useState } from 'react'
-import { Check, Clipboard, Copy, Loader2, RotateCcw, ThumbsDown, ThumbsUp, X } from 'lucide-react'
-import ReactMarkdown from 'react-markdown'
-import remarkGfm from 'remark-gfm'
-import { Button } from '@/components/ui/button'
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { type FC, memo, useEffect, useMemo, useRef, useState } from 'react'
+import { Check, Clipboard, Loader2, RotateCcw, ThumbsDown, ThumbsUp, X } from 'lucide-react'
import { InlineToolCall } from '@/lib/copilot/tools/inline-tool-call'
+import { createLogger } from '@/lib/logs/console/logger'
import { usePreviewStore } from '@/stores/copilot/preview-store'
import { useCopilotStore } from '@/stores/copilot/store'
import type { CopilotMessage as CopilotMessageType } from '@/stores/copilot/types'
+import CopilotMarkdownRenderer from './components/markdown-renderer'
+
+const logger = createLogger('CopilotMessage')
interface CopilotMessageProps {
message: CopilotMessageType
isStreaming?: boolean
}
-// Link component with preview
-function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) {
- return (
-
-
-
- {children}
-
-
-
- {href}
-
-
- )
-}
-
// Memoized streaming indicator component for better performance
const StreamingIndicator = memo(() => (
@@ -63,11 +42,10 @@ StreamingIndicator.displayName = 'StreamingIndicator'
interface SmoothStreamingTextProps {
content: string
isStreaming: boolean
- markdownComponents: any
}
const SmoothStreamingText = memo(
- ({ content, isStreaming, markdownComponents }: SmoothStreamingTextProps) => {
+ ({ content, isStreaming }: SmoothStreamingTextProps) => {
const [displayedContent, setDisplayedContent] = useState('')
const contentRef = useRef(content)
const timeoutRef = useRef
(null)
@@ -147,11 +125,7 @@ const SmoothStreamingText = memo(
return (
-
-
- {displayedContent}
-
-
+
)
},
@@ -293,20 +267,12 @@ const CopilotMessage: FC = memo(
toolCall.input?.data?.yamlContent
if (yamlContent && typeof yamlContent === 'string' && yamlContent.trim()) {
- console.log('Found workflow YAML in tool call:', {
- toolCallId: toolCall.id,
- toolName: toolCall.name,
- yamlLength: yamlContent.length,
- })
return yamlContent
}
}
// Step 2: Check copilot store's preview YAML (set when workflow tools execute)
if (currentChat?.previewYaml?.trim()) {
- console.log('Found workflow YAML in copilot store preview:', {
- yamlLength: currentChat.previewYaml.length,
- })
return currentChat.previewYaml
}
@@ -315,11 +281,6 @@ const CopilotMessage: FC = memo(
if (toolCall.id) {
const preview = getPreviewByToolCall(toolCall.id)
if (preview?.yamlContent?.trim()) {
- console.log('Found workflow YAML in preview store:', {
- toolCallId: toolCall.id,
- previewId: preview.id,
- yamlLength: preview.yamlContent.length,
- })
return preview.yamlContent
}
}
@@ -330,10 +291,6 @@ const CopilotMessage: FC = memo(
if (workflowTools.length > 0 && workflowId) {
const latestPreview = getLatestPendingPreview(workflowId, currentChat?.id)
if (latestPreview?.yamlContent?.trim()) {
- console.log('Found workflow YAML in latest pending preview:', {
- previewId: latestPreview.id,
- yamlLength: latestPreview.yamlContent.length,
- })
return latestPreview.yamlContent
}
}
@@ -345,19 +302,19 @@ const CopilotMessage: FC = memo(
const submitFeedback = async (isPositive: boolean) => {
// Ensure we have a chat ID
if (!currentChat?.id) {
- console.error('No current chat ID available for feedback submission')
+ logger.error('No current chat ID available for feedback submission')
return
}
const userQuery = getLastUserQuery()
if (!userQuery) {
- console.error('No user query found for feedback submission')
+ logger.error('No user query found for feedback submission')
return
}
const agentResponse = getFullAssistantContent(message)
if (!agentResponse.trim()) {
- console.error('No agent response content available for feedback submission')
+ logger.error('No agent response content available for feedback submission')
return
}
@@ -375,10 +332,6 @@ const CopilotMessage: FC = memo(
// Only include workflowYaml if it exists
if (workflowYaml) {
requestBody.workflowYaml = workflowYaml
- console.log('Including workflow YAML in feedback:', {
- yamlLength: workflowYaml.length,
- yamlPreview: workflowYaml.substring(0, 100),
- })
}
const response = await fetch('/api/copilot/feedback', {
@@ -394,10 +347,8 @@ const CopilotMessage: FC = memo(
}
const result = await response.json()
- console.log('Feedback submitted successfully:', result)
} catch (error) {
- console.error('Error submitting feedback:', error)
- // Could show a toast or error message to user here
+ logger.error('Error submitting feedback:', error)
}
}
@@ -431,7 +382,7 @@ const CopilotMessage: FC = memo(
await revertToCheckpoint(latestCheckpoint.id)
setShowRestoreConfirmation(false)
} catch (error) {
- console.error('Failed to revert to checkpoint:', error)
+ logger.error('Failed to revert to checkpoint:', error)
setShowRestoreConfirmation(false)
}
}
@@ -476,194 +427,6 @@ const CopilotMessage: FC = memo(
return message.content.replace(/\n{3,}/g, '\n\n')
}, [message.content])
- // Custom components for react-markdown with current styling - memoized to prevent re-renders
- const markdownComponents = useMemo(
- () => ({
- // Paragraph
- p: ({ children }: React.HTMLAttributes) => (
-
- {children}
-
- ),
-
- // Headings
- h1: ({ children }: React.HTMLAttributes) => (
-
- {children}
-
- ),
- h2: ({ children }: React.HTMLAttributes) => (
-
- {children}
-
- ),
- h3: ({ children }: React.HTMLAttributes) => (
-
- {children}
-
- ),
- h4: ({ children }: React.HTMLAttributes) => (
-
- {children}
-
- ),
-
- // Lists
- ul: ({ children }: React.HTMLAttributes) => (
-
- ),
- ol: ({ children }: React.HTMLAttributes) => (
-
- {children}
-
- ),
- li: ({
- children,
- ordered,
- }: React.LiHTMLAttributes & { ordered?: boolean }) => (
-
- {children}
-
- ),
-
- // Code blocks
- 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'
- }
-
- return (
-
-
- {language}
-
-
-
-
- )
- },
-
- // Inline code
- code: ({
- inline,
- className,
- children,
- ...props
- }: React.HTMLAttributes & { className?: string; inline?: boolean }) => {
- if (inline) {
- return (
-
- {children}
-
- )
- }
- return (
-
- {children}
-
- )
- },
-
- // Blockquotes
- blockquote: ({ children }: React.HTMLAttributes) => (
-
- {children}
-
- ),
-
- // Horizontal rule
- hr: () =>
,
-
- // Links
- a: ({ href, children, ...props }: React.AnchorHTMLAttributes) => (
-
- {children}
-
- ),
-
- // Tables
- table: ({ children }: React.TableHTMLAttributes) => (
-
- ),
- 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) => (
-
- ),
- }),
- []
- )
-
// Memoize content blocks to avoid re-rendering unchanged blocks
const memoizedContentBlocks = useMemo(() => {
if (!message.contentBlocks || message.contentBlocks.length === 0) {
@@ -693,17 +456,9 @@ const CopilotMessage: FC = memo(
}}
>
{shouldUseSmoothing ? (
-
+
) : (
-
-
- {cleanBlockContent}
-
-
+
)}
)
@@ -721,7 +476,7 @@ const CopilotMessage: FC = memo(
}
return null
})
- }, [message.contentBlocks, isStreaming, markdownComponents])
+ }, [message.contentBlocks, isStreaming])
if (isUser) {
return (
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 0cd241369f..1ffa1f397a 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
@@ -1,6 +1,13 @@
'use client'
-import { type FC, type KeyboardEvent, useEffect, useRef, useState } from 'react'
+import {
+ forwardRef,
+ type KeyboardEvent,
+ useEffect,
+ useImperativeHandle,
+ useRef,
+ useState,
+} from 'react'
import { ArrowUp, Loader2, MessageCircle, Package, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
@@ -20,151 +27,174 @@ interface UserInputProps {
onChange?: (value: string) => void // Callback when value changes
}
-const UserInput: FC = ({
- onSubmit,
- onAbort,
- disabled = false,
- isLoading = false,
- isAborting = false,
- placeholder = 'How can I help you today?',
- className,
- mode = 'agent',
- onModeChange,
- value: controlledValue,
- onChange: onControlledChange,
-}) => {
- const [internalMessage, setInternalMessage] = useState('')
- const textareaRef = useRef(null)
-
- // Use controlled value if provided, otherwise use internal state
- const message = controlledValue !== undefined ? controlledValue : internalMessage
- const setMessage =
- controlledValue !== undefined ? onControlledChange || (() => {}) : setInternalMessage
-
- // Auto-resize textarea
- useEffect(() => {
- const textarea = textareaRef.current
- if (textarea) {
- textarea.style.height = 'auto'
- textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px` // Max height of 120px
- }
- }, [message])
-
- const handleSubmit = () => {
- const trimmedMessage = message.trim()
- if (!trimmedMessage || disabled || isLoading) return
-
- onSubmit(trimmedMessage)
- // Clear the message after submit
- if (controlledValue !== undefined) {
- onControlledChange?.('')
- } else {
- setInternalMessage('')
- }
- }
-
- const handleAbort = () => {
- if (onAbort && isLoading) {
- onAbort()
- }
- }
-
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault()
- handleSubmit()
- }
- }
-
- const handleInputChange = (e: React.ChangeEvent) => {
- const newValue = e.target.value
- if (controlledValue !== undefined) {
- onControlledChange?.(newValue)
- } else {
- setInternalMessage(newValue)
- }
- }
-
- const canSubmit = message.trim().length > 0 && !disabled && !isLoading
- const showAbortButton = isLoading && onAbort
-
- const handleModeToggle = () => {
- if (onModeChange) {
- onModeChange(mode === 'ask' ? 'agent' : 'ask')
- }
- }
-
- const getModeIcon = () => {
- return mode === 'ask' ? (
-
- ) : (
-
- )
- }
-
- return (
-
-
- {/* Textarea Field */}
-
-
- {/* Bottom Row: Mode Selector + Send Button */}
-
- {/* Mode Selector Tag */}
-
-
- {/* Send Button */}
- {showAbortButton ? (
-
- ) : (
-
- )}
-
-
-
- )
+interface UserInputRef {
+ focus: () => void
}
+const UserInput = forwardRef(
+ (
+ {
+ onSubmit,
+ onAbort,
+ disabled = false,
+ isLoading = false,
+ isAborting = false,
+ placeholder = 'How can I help you today?',
+ className,
+ mode = 'agent',
+ onModeChange,
+ value: controlledValue,
+ onChange: onControlledChange,
+ },
+ ref
+ ) => {
+ const [internalMessage, setInternalMessage] = useState('')
+ const textareaRef = useRef(null)
+
+ // Expose focus method to parent
+ useImperativeHandle(
+ ref,
+ () => ({
+ focus: () => {
+ textareaRef.current?.focus()
+ },
+ }),
+ []
+ )
+
+ // Use controlled value if provided, otherwise use internal state
+ const message = controlledValue !== undefined ? controlledValue : internalMessage
+ const setMessage =
+ controlledValue !== undefined ? onControlledChange || (() => {}) : setInternalMessage
+
+ // Auto-resize textarea
+ useEffect(() => {
+ const textarea = textareaRef.current
+ if (textarea) {
+ textarea.style.height = 'auto'
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px` // Max height of 120px
+ }
+ }, [message])
+
+ const handleSubmit = () => {
+ const trimmedMessage = message.trim()
+ if (!trimmedMessage || disabled || isLoading) return
+
+ onSubmit(trimmedMessage)
+ // Clear the message after submit
+ if (controlledValue !== undefined) {
+ onControlledChange?.('')
+ } else {
+ setInternalMessage('')
+ }
+ }
+
+ const handleAbort = () => {
+ if (onAbort && isLoading) {
+ onAbort()
+ }
+ }
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ handleSubmit()
+ }
+ }
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const newValue = e.target.value
+ if (controlledValue !== undefined) {
+ onControlledChange?.(newValue)
+ } else {
+ setInternalMessage(newValue)
+ }
+ }
+
+ const canSubmit = message.trim().length > 0 && !disabled && !isLoading
+ const showAbortButton = isLoading && onAbort
+
+ const handleModeToggle = () => {
+ if (onModeChange) {
+ onModeChange(mode === 'ask' ? 'agent' : 'ask')
+ }
+ }
+
+ const getModeIcon = () => {
+ return mode === 'ask' ? (
+
+ ) : (
+
+ )
+ }
+
+ return (
+
+
+ {/* Textarea Field */}
+
+
+ {/* Bottom Row: Mode Selector + Send Button */}
+
+ {/* Mode Selector Tag */}
+
+
+ {/* Send Button */}
+ {showAbortButton ? (
+
+ ) : (
+
+ )}
+
+
+
+ )
+ }
+)
+
+UserInput.displayName = 'UserInput'
+
export { UserInput }
+export type { UserInputRef }
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx
index 414932c29e..d2628e3a1e 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx
@@ -1,6 +1,8 @@
'use client'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
+import { ArrowDown } from 'lucide-react'
+import { Button } from '@/components/ui/button'
import { LoadingAgent } from '@/components/ui/loading-agent'
import { ScrollArea } from '@/components/ui/scroll-area'
import { createLogger } from '@/lib/logs/console/logger'
@@ -10,6 +12,7 @@ import {
CopilotWelcome,
UserInput,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
+import type { UserInputRef } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
import { COPILOT_TOOL_IDS } from '@/stores/copilot/constants'
import { usePreviewStore } from '@/stores/copilot/preview-store'
import { useCopilotStore } from '@/stores/copilot/store'
@@ -27,11 +30,16 @@ interface CopilotRef {
export const Copilot = forwardRef(({ panelWidth }, ref) => {
const scrollAreaRef = useRef(null)
+ const userInputRef = useRef(null)
const [showCheckpoints] = useState(false)
const [isInitialized, setIsInitialized] = useState(false)
const lastWorkflowIdRef = useRef(null)
const hasMountedRef = useRef(false)
+ // Scroll state
+ const [isNearBottom, setIsNearBottom] = useState(true)
+ const [showScrollButton, setShowScrollButton] = useState(false)
+
const { activeWorkflowId } = useWorkflowRegistry()
// Use preview store to track seen previews
@@ -97,29 +105,94 @@ export const Copilot = forwardRef(({ panelWidth }, ref
// Preview clearing is now handled automatically by the copilot store
}, [activeWorkflowId])
- // Auto-scroll to bottom when new messages are added
- useEffect(() => {
+ // Scroll to bottom function
+ const scrollToBottom = useCallback(() => {
if (scrollAreaRef.current) {
const scrollContainer = scrollAreaRef.current.querySelector(
'[data-radix-scroll-area-viewport]'
)
if (scrollContainer) {
- scrollContainer.scrollTop = scrollContainer.scrollHeight
+ scrollContainer.scrollTo({
+ top: scrollContainer.scrollHeight,
+ behavior: 'smooth',
+ })
}
}
- }, [messages])
+ }, [])
- // Auto-scroll to bottom when chat loads in
+ // Handle scroll events to track user position
+ const handleScroll = useCallback(() => {
+ const scrollArea = scrollAreaRef.current
+ if (!scrollArea) return
+
+ // Find the viewport element inside the ScrollArea
+ const viewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]')
+ if (!viewport) return
+
+ const { scrollTop, scrollHeight, clientHeight } = viewport
+ const distanceFromBottom = scrollHeight - scrollTop - clientHeight
+
+ // Consider "near bottom" if within 100px of bottom
+ const nearBottom = distanceFromBottom <= 100
+ setIsNearBottom(nearBottom)
+ setShowScrollButton(!nearBottom)
+ }, [])
+
+ // Attach scroll listener
useEffect(() => {
- if (isInitialized && messages.length > 0 && scrollAreaRef.current) {
+ const scrollArea = scrollAreaRef.current
+ if (!scrollArea) return
+
+ // Find the viewport element inside the ScrollArea
+ const viewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]')
+ if (!viewport) return
+
+ viewport.addEventListener('scroll', handleScroll, { passive: true })
+
+ // Also listen for scrollend event if available (for smooth scrolling)
+ if ('onscrollend' in viewport) {
+ viewport.addEventListener('scrollend', handleScroll, { passive: true })
+ }
+
+ // Initial scroll state check with small delay to ensure DOM is ready
+ setTimeout(handleScroll, 100)
+
+ return () => {
+ viewport.removeEventListener('scroll', handleScroll)
+ if ('onscrollend' in viewport) {
+ viewport.removeEventListener('scrollend', handleScroll)
+ }
+ }
+ }, [handleScroll])
+
+ // Smart auto-scroll: only scroll if user is near bottom or for user messages
+ useEffect(() => {
+ if (messages.length === 0) return
+
+ const lastMessage = messages[messages.length - 1]
+ const isNewUserMessage = lastMessage?.role === 'user'
+
+ // Always scroll for new user messages, or only if near bottom for assistant messages
+ if ((isNewUserMessage || isNearBottom) && scrollAreaRef.current) {
const scrollContainer = scrollAreaRef.current.querySelector(
'[data-radix-scroll-area-viewport]'
)
if (scrollContainer) {
- scrollContainer.scrollTop = scrollContainer.scrollHeight
+ scrollContainer.scrollTo({
+ top: scrollContainer.scrollHeight,
+ behavior: 'smooth',
+ })
+ // Let the scroll event handler update the state naturally after animation completes
}
}
- }, [isInitialized, messages.length])
+ }, [messages, isNearBottom])
+
+ // Auto-scroll to bottom when chat loads in
+ useEffect(() => {
+ if (isInitialized && messages.length > 0) {
+ scrollToBottom()
+ }
+ }, [isInitialized, messages.length, scrollToBottom])
// Cleanup on component unmount (page refresh, navigation, etc.)
useEffect(() => {
@@ -160,6 +233,11 @@ export const Copilot = forwardRef(({ panelWidth }, ref
// Preview clearing is now handled automatically by the copilot store
createNewChat()
logger.info('Started new chat')
+
+ // Focus the input after creating new chat
+ setTimeout(() => {
+ userInputRef.current?.focus()
+ }, 100) // Small delay to ensure DOM updates are complete
}, [createNewChat])
// Expose functions to parent
@@ -203,34 +281,48 @@ export const Copilot = forwardRef(({ panelWidth }, ref
{showCheckpoints ? (
) : (
-
-
- {messages.length === 0 ? (
-
-
-
- ) : (
- messages.map((message) => (
-
- ))
- )}
-
-
+
+
+
+ {messages.length === 0 ? (
+
+
+
+ ) : (
+ messages.map((message) => (
+
+ ))
+ )}
+
+
+
+ {/* Scroll to bottom button */}
+ {showScrollButton && (
+
+
+
+ )}
+
)}
{/* Input area with integrated mode selector */}
{!showCheckpoints && (