mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 06:58:07 -05:00
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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user