fix(copilot): added user scrolling, fixed code block, fixed code copying and styling (#872)

* fix(copilot-ui): added user scrolling, fixed code block, fixed code copying and styling

* use console logger instead of console

* fix(copilot): make chat history non-interfering (#869)

* Add basic personalizatoin

* Make chat history non-interfering

* Always personalize

* improvement(copilot): add subblock enums to block metadata (#870)

* Add subblock enums to metadata

* Update apps/sim/lib/copilot/tools/server-tools/blocks/get-blocks-metadata.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix(copilot): fix state message sent on move to background (#871)

* Initial fix

* Add execution start time to message

* Lint

* autofocus on new tab open

---------

Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Waleed Latif
2025-08-04 19:49:53 -07:00
committed by GitHub
parent 221a473ccc
commit 41b1357afb
7 changed files with 766 additions and 448 deletions

View File

@@ -168,7 +168,7 @@ export const ChatInput: React.FC<{
ref={textareaRef}
value={inputValue}
onChange={handleInputChange}
className='flex w-full resize-none items-center overflow-hidden bg-transparent text-sm outline-none placeholder:text-gray-400 md:font-[330] md:text-base'
className='flex w-full resize-none items-center overflow-hidden bg-transparent text-base outline-none placeholder:text-gray-400 md:font-[330]'
placeholder={isActive ? '' : ''}
rows={1}
style={{
@@ -191,14 +191,14 @@ export const ChatInput: React.FC<{
<>
{/* Mobile placeholder */}
<div
className='-translate-y-1/2 absolute top-1/2 left-0 transform select-none text-gray-400 text-sm md:hidden md:text-base'
className='-translate-y-1/2 absolute top-1/2 left-0 transform select-none text-base text-gray-400 md:hidden'
style={{ paddingTop: '3px', paddingBottom: '3px' }}
>
{PLACEHOLDER_MOBILE}
</div>
{/* Desktop placeholder */}
<div
className='-translate-y-1/2 absolute top-1/2 left-0 hidden transform select-none font-[330] text-gray-400 text-sm md:block md:text-base'
className='-translate-y-1/2 absolute top-1/2 left-0 hidden transform select-none font-[330] text-base text-gray-400 md:block'
style={{ paddingTop: '4px', paddingBottom: '4px' }}
>
{PLACEHOLDER_DESKTOP}

View File

@@ -42,7 +42,7 @@ export const ClientChatMessage = memo(
<div className='mx-auto max-w-3xl'>
<div className='flex justify-end'>
<div className='max-w-[80%] rounded-3xl bg-[#F4F4F4] px-4 py-3 dark:bg-gray-600'>
<div className='whitespace-pre-wrap break-words text-gray-800 text-lg leading-relaxed dark:text-gray-100'>
<div className='whitespace-pre-wrap break-words text-base text-gray-800 leading-relaxed dark:text-gray-100'>
{isJsonObject ? (
<pre>{JSON.stringify(message.content, null, 2)}</pre>
) : (

View File

@@ -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<HTMLDivElement>(null)
const scrollAreaRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
@@ -50,6 +51,10 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
const [promptHistory, setPromptHistory] = useState<string[]>([])
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
</div>
) : (
<ScrollArea className='h-full pb-2' hideScrollbar={true}>
<ScrollArea ref={scrollAreaRef} className='h-full pb-2' hideScrollbar={true}>
<div>
{workflowMessages.map((message) => (
<ChatMessage key={message.id} message={message} />
@@ -458,6 +523,21 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
</div>
</ScrollArea>
)}
{/* Scroll to bottom button */}
{showScrollButton && (
<div className='-translate-x-1/2 absolute bottom-20 left-1/2 z-10'>
<Button
onClick={scrollToBottom}
size='sm'
variant='outline'
className='flex items-center gap-1 rounded-full border border-gray-200 bg-white px-3 py-1 shadow-lg transition-all hover:bg-gray-50'
>
<ArrowDown className='h-3.5 w-3.5' />
<span className='sr-only'>Scroll to bottom</span>
</Button>
</div>
)}
</div>
{/* Input section - Fixed height */}

View File

@@ -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 (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<a
href={href}
className='text-blue-600 hover:underline dark:text-blue-400'
target='_blank'
rel='noopener noreferrer'
>
{children}
</a>
</TooltipTrigger>
<TooltipContent side='top' align='center' sideOffset={5} className='max-w-sm p-3'>
<span className='text-sm'>{href}</span>
</TooltipContent>
</Tooltip>
)
}
interface CopilotMarkdownRendererProps {
content: string
}
export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRendererProps) {
const [copiedCodeBlocks, setCopiedCodeBlocks] = useState<Record<string, boolean>>({})
// Reset copy success state after 2 seconds
useEffect(() => {
const timers: Record<string, NodeJS.Timeout> = {}
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<HTMLParagraphElement>) => (
<p className='mb-1 font-geist-sans text-base text-gray-800 leading-relaxed last:mb-0 dark:text-gray-200'>
{children}
</p>
),
// Headings
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h1 className='mt-10 mb-5 font-geist-sans font-semibold text-2xl text-gray-900 dark:text-gray-100'>
{children}
</h1>
),
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2 className='mt-8 mb-4 font-geist-sans font-semibold text-gray-900 text-xl dark:text-gray-100'>
{children}
</h2>
),
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3 className='mt-7 mb-3 font-geist-sans font-semibold text-gray-900 text-lg dark:text-gray-100'>
{children}
</h3>
),
h4: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h4 className='mt-5 mb-2 font-geist-sans font-semibold text-base text-gray-900 dark:text-gray-100'>
{children}
</h4>
),
// Lists
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
<ul
className='mt-1 mb-1 space-y-1 pl-6 font-geist-sans text-gray-800 dark:text-gray-200'
style={{ listStyleType: 'disc' }}
>
{children}
</ul>
),
ol: ({ children }: React.HTMLAttributes<HTMLOListElement>) => (
<ol
className='mt-1 mb-1 space-y-1 pl-6 font-geist-sans text-gray-800 dark:text-gray-200'
style={{ listStyleType: 'decimal' }}
>
{children}
</ol>
),
li: ({
children,
ordered,
}: React.LiHTMLAttributes<HTMLLIElement> & { ordered?: boolean }) => (
<li
className='font-geist-sans text-gray-800 dark:text-gray-200'
style={{ display: 'list-item' }}
>
{children}
</li>
),
// Code blocks
pre: ({ children }: React.HTMLAttributes<HTMLPreElement>) => {
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 (
<div className='my-6 w-0 min-w-full rounded-md bg-gray-900 text-sm dark:bg-black'>
<div className='flex items-center justify-between border-gray-700 border-b px-4 py-1.5 dark:border-gray-800'>
<span className='font-geist-sans text-gray-400 text-xs'>{language}</span>
<button
onClick={handleCopy}
className='text-muted-foreground transition-colors hover:text-gray-300'
title='Copy'
>
{showCopySuccess ? (
<Check className='h-3 w-3' strokeWidth={2} />
) : (
<Copy className='h-3 w-3' strokeWidth={2} />
)}
</button>
</div>
<div className='overflow-x-auto'>
<pre className='whitespace-pre p-4 font-mono text-gray-100 text-sm leading-relaxed'>
{actualCodeText}
</pre>
</div>
</div>
)
},
// Inline code
code: ({
inline,
className,
children,
...props
}: React.HTMLAttributes<HTMLElement> & { className?: string; inline?: boolean }) => {
if (inline) {
return (
<code
className='rounded bg-gray-200 px-1 py-0.5 font-mono text-[0.9em] text-gray-800 dark:bg-gray-700 dark:text-gray-200'
{...props}
>
{children}
</code>
)
}
return (
<code className={className} {...props}>
{children}
</code>
)
},
// Bold text
strong: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<strong className='font-semibold text-gray-900 dark:text-gray-100'>{children}</strong>
),
// Bold text (alternative)
b: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<b className='font-semibold text-gray-900 dark:text-gray-100'>{children}</b>
),
// Italic text
em: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<em className='text-gray-800 italic dark:text-gray-200'>{children}</em>
),
// Italic text (alternative)
i: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<i className='text-gray-800 italic dark:text-gray-200'>{children}</i>
),
// Blockquotes
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
<blockquote className='my-4 border-gray-300 border-l-4 py-1 pl-4 font-geist-sans text-gray-700 italic dark:border-gray-600 dark:text-gray-300'>
{children}
</blockquote>
),
// Horizontal rule
hr: () => <hr className='my-8 border-gray-500/[.07] border-t dark:border-gray-400/[.07]' />,
// Links
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<LinkWithPreview href={href || '#'} {...props}>
{children}
</LinkWithPreview>
),
// Tables
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
<div className='my-4 max-w-full overflow-x-auto'>
<table className='min-w-full table-auto border border-gray-300 font-geist-sans text-sm dark:border-gray-700'>
{children}
</table>
</div>
),
thead: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<thead className='bg-gray-100 text-left dark:bg-gray-800'>{children}</thead>
),
tbody: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>{children}</tbody>
),
tr: ({ children }: React.HTMLAttributes<HTMLTableRowElement>) => (
<tr className='border-gray-200 border-b transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800/60'>
{children}
</tr>
),
th: ({ children }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
<th className='border-gray-300 border-r px-4 py-2 font-medium text-gray-700 last:border-r-0 dark:border-gray-700 dark:text-gray-300'>
{children}
</th>
),
td: ({ children }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
<td className='break-words border-gray-300 border-r px-4 py-2 text-gray-800 last:border-r-0 dark:border-gray-700 dark:text-gray-200'>
{children}
</td>
),
// Images
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
<img
src={src}
alt={alt || 'Image'}
className='my-3 h-auto max-w-full rounded-md'
{...props}
/>
),
}),
[copiedCodeBlocks]
)
return (
<div className='copilot-markdown-wrapper max-w-full space-y-4 break-words font-geist-sans text-[#0D0D0D] text-base leading-relaxed dark:text-gray-100'>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{content}
</ReactMarkdown>
</div>
)
}

View File

@@ -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 (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<a
href={href}
className='text-blue-600 hover:underline dark:text-blue-400'
target='_blank'
rel='noopener noreferrer'
>
{children}
</a>
</TooltipTrigger>
<TooltipContent side='top' align='center' sideOffset={5} className='max-w-sm p-3'>
<span className='text-sm'>{href}</span>
</TooltipContent>
</Tooltip>
)
}
// Memoized streaming indicator component for better performance
const StreamingIndicator = memo(() => (
<div className='flex items-center py-1 text-muted-foreground transition-opacity duration-200 ease-in-out'>
@@ -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<NodeJS.Timeout | null>(null)
@@ -147,11 +125,7 @@ const SmoothStreamingText = memo(
return (
<div className='relative max-w-full overflow-hidden' style={{ minHeight: '1.25rem' }}>
<div className='max-w-full space-y-4 break-words font-geist-sans text-[#0D0D0D] text-base leading-relaxed dark:text-gray-100'>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{displayedContent}
</ReactMarkdown>
</div>
<CopilotMarkdownRenderer content={displayedContent} />
</div>
)
},
@@ -293,20 +267,12 @@ const CopilotMessage: FC<CopilotMessageProps> = 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<CopilotMessageProps> = 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<CopilotMessageProps> = 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<CopilotMessageProps> = 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<CopilotMessageProps> = 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<CopilotMessageProps> = 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<CopilotMessageProps> = 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<CopilotMessageProps> = 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<HTMLParagraphElement>) => (
<p className='mb-1 font-geist-sans text-base text-gray-800 leading-relaxed last:mb-0 dark:text-gray-200'>
{children}
</p>
),
// Headings
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h1 className='mt-10 mb-5 font-geist-sans font-semibold text-2xl text-gray-900 dark:text-gray-100'>
{children}
</h1>
),
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2 className='mt-8 mb-4 font-geist-sans font-semibold text-gray-900 text-xl dark:text-gray-100'>
{children}
</h2>
),
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3 className='mt-7 mb-3 font-geist-sans font-semibold text-gray-900 text-lg dark:text-gray-100'>
{children}
</h3>
),
h4: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h4 className='mt-5 mb-2 font-geist-sans font-semibold text-base text-gray-900 dark:text-gray-100'>
{children}
</h4>
),
// Lists
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
<ul
className='mt-1 mb-1 space-y-1 pl-6 font-geist-sans text-gray-800 dark:text-gray-200'
style={{ listStyleType: 'disc' }}
>
{children}
</ul>
),
ol: ({ children }: React.HTMLAttributes<HTMLOListElement>) => (
<ol
className='mt-1 mb-1 space-y-1 pl-6 font-geist-sans text-gray-800 dark:text-gray-200'
style={{ listStyleType: 'decimal' }}
>
{children}
</ol>
),
li: ({
children,
ordered,
}: React.LiHTMLAttributes<HTMLLIElement> & { ordered?: boolean }) => (
<li
className='font-geist-sans text-gray-800 dark:text-gray-200'
style={{ display: 'list-item' }}
>
{children}
</li>
),
// Code blocks
pre: ({ children }: React.HTMLAttributes<HTMLPreElement>) => {
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 (
<div className='my-6 w-0 min-w-full rounded-md bg-gray-900 text-sm dark:bg-black'>
<div className='flex items-center justify-between border-gray-700 border-b px-4 py-1.5 dark:border-gray-800'>
<span className='font-geist-sans text-gray-400 text-xs'>{language}</span>
<Button
variant='ghost'
size='sm'
className='h-4 w-4 p-0 opacity-70 hover:opacity-100'
onClick={() => {
if (typeof codeContent === 'string') {
navigator.clipboard.writeText(codeContent)
}
}}
>
<Copy className='h-3 w-3 text-gray-400' />
</Button>
</div>
<div className='overflow-x-auto'>
<pre className='whitespace-pre p-4 font-mono text-gray-100 text-sm leading-relaxed'>
{codeContent}
</pre>
</div>
</div>
)
},
// Inline code
code: ({
inline,
className,
children,
...props
}: React.HTMLAttributes<HTMLElement> & { className?: string; inline?: boolean }) => {
if (inline) {
return (
<code
className='rounded bg-gray-200 px-1 py-0.5 font-mono text-[0.9em] text-gray-800 dark:bg-gray-700 dark:text-gray-200'
{...props}
>
{children}
</code>
)
}
return (
<code className={className} {...props}>
{children}
</code>
)
},
// Blockquotes
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
<blockquote className='my-4 border-gray-300 border-l-4 py-1 pl-4 font-geist-sans text-gray-700 italic dark:border-gray-600 dark:text-gray-300'>
{children}
</blockquote>
),
// Horizontal rule
hr: () => <hr className='my-8 border-gray-500/[.07] border-t dark:border-gray-400/[.07]' />,
// Links
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<LinkWithPreview href={href || '#'} {...props}>
{children}
</LinkWithPreview>
),
// Tables
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
<div className='my-4 max-w-full overflow-x-auto'>
<table className='min-w-full table-auto border border-gray-300 font-geist-sans text-sm dark:border-gray-700'>
{children}
</table>
</div>
),
thead: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<thead className='bg-gray-100 text-left dark:bg-gray-800'>{children}</thead>
),
tbody: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>{children}</tbody>
),
tr: ({ children }: React.HTMLAttributes<HTMLTableRowElement>) => (
<tr className='border-gray-200 border-b transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800/60'>
{children}
</tr>
),
th: ({ children }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
<th className='border-gray-300 border-r px-4 py-2 font-medium text-gray-700 last:border-r-0 dark:border-gray-700 dark:text-gray-300'>
{children}
</th>
),
td: ({ children }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
<td className='break-words border-gray-300 border-r px-4 py-2 text-gray-800 last:border-r-0 dark:border-gray-700 dark:text-gray-200'>
{children}
</td>
),
// Images
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
<img
src={src}
alt={alt || 'Image'}
className='my-3 h-auto max-w-full rounded-md'
{...props}
/>
),
}),
[]
)
// 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<CopilotMessageProps> = memo(
}}
>
{shouldUseSmoothing ? (
<SmoothStreamingText
content={cleanBlockContent}
isStreaming={isStreaming}
markdownComponents={markdownComponents}
/>
<SmoothStreamingText content={cleanBlockContent} isStreaming={isStreaming} />
) : (
<div className='max-w-full space-y-4 break-words font-geist-sans text-[#0D0D0D] text-base leading-relaxed dark:text-gray-100'>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{cleanBlockContent}
</ReactMarkdown>
</div>
<CopilotMarkdownRenderer content={cleanBlockContent} />
)}
</div>
)
@@ -721,7 +476,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
}
return null
})
}, [message.contentBlocks, isStreaming, markdownComponents])
}, [message.contentBlocks, isStreaming])
if (isUser) {
return (

View File

@@ -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<UserInputProps> = ({
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<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
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' ? (
<MessageCircle className='h-3 w-3 text-muted-foreground' />
) : (
<Package className='h-3 w-3 text-muted-foreground' />
)
}
return (
<div className={cn('relative flex-none pb-4', className)}>
<div className='rounded-[8px] border border-[#E5E5E5] bg-[#FFFFFF] p-2 shadow-xs dark:border-[#414141] dark:bg-[#202020]'>
{/* Textarea Field */}
<Textarea
ref={textareaRef}
value={message}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
className='mb-2 min-h-[32px] w-full resize-none overflow-hidden border-0 bg-transparent px-[2px] py-1 text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
style={{ height: 'auto' }}
/>
{/* Bottom Row: Mode Selector + Send Button */}
<div className='flex items-center justify-between'>
{/* Mode Selector Tag */}
<Button
variant='ghost'
size='sm'
onClick={handleModeToggle}
disabled={!onModeChange}
className='flex h-6 items-center gap-1.5 rounded-full bg-secondary px-2 py-1 font-medium text-secondary-foreground text-xs hover:bg-secondary/80'
>
{getModeIcon()}
<span className='capitalize'>{mode}</span>
</Button>
{/* Send Button */}
{showAbortButton ? (
<Button
onClick={handleAbort}
disabled={isAborting}
size='icon'
className='h-6 w-6 rounded-full bg-red-500 text-white transition-all duration-200 hover:bg-red-600'
title='Stop generation'
>
{isAborting ? (
<Loader2 className='h-3 w-3 animate-spin' />
) : (
<X className='h-3 w-3' />
)}
</Button>
) : (
<Button
onClick={handleSubmit}
disabled={!canSubmit}
size='icon'
className='h-6 w-6 rounded-full bg-[#802FFF] text-white shadow-[0_0_0_0_#802FFF] transition-all duration-200 hover:bg-[#7028E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
>
{isLoading ? (
<Loader2 className='h-3 w-3 animate-spin' />
) : (
<ArrowUp className='h-3 w-3' />
)}
</Button>
)}
</div>
</div>
</div>
)
interface UserInputRef {
focus: () => void
}
const UserInput = forwardRef<UserInputRef, UserInputProps>(
(
{
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<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
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' ? (
<MessageCircle className='h-3 w-3 text-muted-foreground' />
) : (
<Package className='h-3 w-3 text-muted-foreground' />
)
}
return (
<div className={cn('relative flex-none pb-4', className)}>
<div className='rounded-[8px] border border-[#E5E5E5] bg-[#FFFFFF] p-2 shadow-xs dark:border-[#414141] dark:bg-[#202020]'>
{/* Textarea Field */}
<Textarea
ref={textareaRef}
value={message}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
className='mb-2 min-h-[32px] w-full resize-none overflow-hidden border-0 bg-transparent px-[2px] py-1 text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
style={{ height: 'auto' }}
/>
{/* Bottom Row: Mode Selector + Send Button */}
<div className='flex items-center justify-between'>
{/* Mode Selector Tag */}
<Button
variant='ghost'
size='sm'
onClick={handleModeToggle}
disabled={!onModeChange}
className='flex h-6 items-center gap-1.5 rounded-full bg-secondary px-2 py-1 font-medium text-secondary-foreground text-xs hover:bg-secondary/80'
>
{getModeIcon()}
<span className='capitalize'>{mode}</span>
</Button>
{/* Send Button */}
{showAbortButton ? (
<Button
onClick={handleAbort}
disabled={isAborting}
size='icon'
className='h-6 w-6 rounded-full bg-red-500 text-white transition-all duration-200 hover:bg-red-600'
title='Stop generation'
>
{isAborting ? (
<Loader2 className='h-3 w-3 animate-spin' />
) : (
<X className='h-3 w-3' />
)}
</Button>
) : (
<Button
onClick={handleSubmit}
disabled={!canSubmit}
size='icon'
className='h-6 w-6 rounded-full bg-[#802FFF] text-white shadow-[0_0_0_0_#802FFF] transition-all duration-200 hover:bg-[#7028E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
>
{isLoading ? (
<Loader2 className='h-3 w-3 animate-spin' />
) : (
<ArrowUp className='h-3 w-3' />
)}
</Button>
)}
</div>
</div>
</div>
)
}
)
UserInput.displayName = 'UserInput'
export { UserInput }
export type { UserInputRef }

View File

@@ -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<CopilotRef, CopilotProps>(({ panelWidth }, ref) => {
const scrollAreaRef = useRef<HTMLDivElement>(null)
const userInputRef = useRef<UserInputRef>(null)
const [showCheckpoints] = useState(false)
const [isInitialized, setIsInitialized] = useState(false)
const lastWorkflowIdRef = useRef<string | null>(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<CopilotRef, CopilotProps>(({ 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<CopilotRef, CopilotProps>(({ 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<CopilotRef, CopilotProps>(({ panelWidth }, ref
{showCheckpoints ? (
<CheckpointPanel />
) : (
<ScrollArea
ref={scrollAreaRef}
className='flex-1 overflow-hidden'
hideScrollbar={true}
>
<div className='w-full max-w-full space-y-1 overflow-hidden'>
{messages.length === 0 ? (
<div className='flex h-full items-center justify-center p-4'>
<CopilotWelcome onQuestionClick={handleSubmit} mode={mode} />
</div>
) : (
messages.map((message) => (
<CopilotMessage
key={message.id}
message={message}
isStreaming={
isSendingMessage && message.id === messages[messages.length - 1]?.id
}
/>
))
)}
</div>
</ScrollArea>
<div className='relative flex-1 overflow-hidden'>
<ScrollArea ref={scrollAreaRef} className='h-full' hideScrollbar={true}>
<div className='w-full max-w-full space-y-1 overflow-hidden'>
{messages.length === 0 ? (
<div className='flex h-full items-center justify-center p-4'>
<CopilotWelcome onQuestionClick={handleSubmit} mode={mode} />
</div>
) : (
messages.map((message) => (
<CopilotMessage
key={message.id}
message={message}
isStreaming={
isSendingMessage && message.id === messages[messages.length - 1]?.id
}
/>
))
)}
</div>
</ScrollArea>
{/* Scroll to bottom button */}
{showScrollButton && (
<div className='-translate-x-1/2 absolute bottom-4 left-1/2 z-10'>
<Button
onClick={scrollToBottom}
size='sm'
variant='outline'
className='flex items-center gap-1 rounded-full border border-gray-200 bg-white px-3 py-1 shadow-lg transition-all hover:bg-gray-50'
>
<ArrowDown className='h-3.5 w-3.5' />
<span className='sr-only'>Scroll to bottom</span>
</Button>
</div>
)}
</div>
)}
{/* Input area with integrated mode selector */}
{!showCheckpoints && (
<UserInput
ref={userInputRef}
onSubmit={handleSubmit}
onAbort={abortMessage}
disabled={!activeWorkflowId}