Add footer fullscreen option

This commit is contained in:
Siddharth Ganesan
2025-07-08 16:49:03 -07:00
parent 7bc644a478
commit 840a028f92
3 changed files with 482 additions and 113 deletions

View File

@@ -0,0 +1,287 @@
'use client'
import { type KeyboardEvent, useEffect, useRef, useState } from 'react'
import { ArrowUp, Bot, User, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { createLogger } from '@/lib/logs/console-logger'
const logger = createLogger('CopilotModal')
interface Message {
id: string
content: string
type: 'user' | 'assistant'
timestamp: Date
citations?: Array<{
id: number
title: string
url: string
}>
}
interface CopilotModalMessage {
message: Message
}
// Modal-specific message component
function ModalCopilotMessage({ message }: CopilotModalMessage) {
const renderCitations = (text: string, citations?: Array<{ id: number; title: string; url: string }>) => {
if (!citations || citations.length === 0) return text
let processedText = text
citations.forEach((citation) => {
const citationRegex = new RegExp(`\\{cite:${citation.id}\\}`, 'g')
processedText = processedText.replace(
citationRegex,
`<a href="${citation.url}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center text-primary hover:text-primary/80 text-sm" title="${citation.title}">↗</a>`
)
})
return processedText
}
const renderMarkdown = (text: string) => {
// Handle citations first
let processedText = renderCitations(text, message.citations)
// Handle code blocks
processedText = processedText.replace(
/```(\w+)?\n([\s\S]*?)\n```/g,
'<pre class="bg-muted rounded-md p-3 my-2 overflow-x-auto"><code class="text-sm">$2</code></pre>'
)
// Handle inline code
processedText = processedText.replace(/`([^`]+)`/g, '<code class="bg-muted px-1 rounded text-sm">$1</code>')
// Handle headers
processedText = processedText.replace(/^### (.*$)/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
processedText = processedText.replace(/^## (.*$)/gm, '<h2 class="text-xl font-semibold mt-4 mb-2">$1</h2>')
processedText = processedText.replace(/^# (.*$)/gm, '<h1 class="text-2xl font-bold mt-4 mb-2">$1</h1>')
// Handle bold
processedText = processedText.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
// Handle lists
processedText = processedText.replace(/^- (.*$)/gm, '<li class="ml-4">• $1</li>')
// Handle line breaks
processedText = processedText.replace(/\n/g, '<br>')
return processedText
}
// For user messages (on the right)
if (message.type === 'user') {
return (
<div className='px-4 py-5'>
<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 shadow-sm dark:bg-primary/10'>
<div className='whitespace-pre-wrap break-words text-[#0D0D0D] text-base leading-relaxed dark:text-white'>
{message.content}
</div>
</div>
</div>
</div>
</div>
)
}
// For assistant messages (on the left)
return (
<div className='px-4 py-5'>
<div className='mx-auto max-w-3xl'>
<div className='flex'>
<div className='max-w-[80%]'>
<div
className='whitespace-pre-wrap break-words text-base leading-relaxed prose prose-sm max-w-none dark:prose-invert'
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
/>
</div>
</div>
</div>
</div>
)
}
interface CopilotModalProps {
open: boolean
onOpenChange: (open: boolean) => void
copilotMessage: string
setCopilotMessage: (message: string) => void
messages: Message[]
onSendMessage: (message: string) => Promise<void>
isLoading: boolean
}
export function CopilotModal({
open,
onOpenChange,
copilotMessage,
setCopilotMessage,
messages,
onSendMessage,
isLoading
}: CopilotModalProps) {
const messagesEndRef = useRef<HTMLDivElement>(null)
const messagesContainerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Auto-scroll to bottom when new messages are added
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [messages])
// Focus input when modal opens
useEffect(() => {
if (open && inputRef.current) {
inputRef.current.focus()
}
}, [open])
// Handle send message
const handleSendMessage = async () => {
if (!copilotMessage.trim() || isLoading) return
try {
await onSendMessage(copilotMessage.trim())
setCopilotMessage('')
// Ensure input stays focused
if (inputRef.current) {
inputRef.current.focus()
}
} catch (error) {
logger.error('Failed to send message', error)
}
}
// Handle key press
const handleKeyPress = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
}
if (!open) return null
return (
<div className='fixed inset-0 z-[100] flex flex-col bg-background'>
<style jsx>{`
@keyframes growShrink {
0%,
100% {
transform: scale(0.9);
}
50% {
transform: scale(1.1);
}
}
.loading-dot {
animation: growShrink 1.5s infinite ease-in-out;
}
`}</style>
{/* Header with title and close button */}
<div className='flex items-center justify-between px-4 py-3'>
<h2 className='font-medium text-lg'>Documentation Copilot</h2>
<Button
variant='ghost'
size='icon'
className='h-8 w-8 rounded-md hover:bg-accent/50'
onClick={() => onOpenChange(false)}
>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
</div>
{/* Messages container */}
<div ref={messagesContainerRef} className='flex-1 overflow-y-auto'>
<div className='mx-auto max-w-3xl'>
{messages.length === 0 ? (
<div className='flex h-full flex-col items-center justify-center px-4 py-10'>
<div className='space-y-4 text-center'>
<Bot className='mx-auto h-12 w-12 text-muted-foreground' />
<div className='space-y-2'>
<h3 className='font-medium text-lg'>Welcome to Documentation Copilot</h3>
<p className='text-muted-foreground text-sm'>
Ask me anything about Sim Studio features, workflows, tools, or how to get started.
</p>
</div>
<div className='space-y-2 text-left max-w-xs mx-auto'>
<div className='text-muted-foreground text-xs'>Try asking:</div>
<div className='space-y-1'>
<div className='rounded bg-muted/50 px-2 py-1 text-xs'>
"How do I create a workflow?"
</div>
<div className='rounded bg-muted/50 px-2 py-1 text-xs'>
"What tools are available?"
</div>
<div className='rounded bg-muted/50 px-2 py-1 text-xs'>
"How do I deploy my workflow?"
</div>
</div>
</div>
</div>
</div>
) : (
messages.map((message) => (
<ModalCopilotMessage key={message.id} message={message} />
))
)}
{/* Loading indicator (shows only when loading) */}
{isLoading && (
<div className='px-4 py-5'>
<div className='mx-auto max-w-3xl'>
<div className='flex'>
<div className='max-w-[80%]'>
<div className='flex h-6 items-center'>
<div className='loading-dot h-3 w-3 rounded-full bg-black dark:bg-black' />
</div>
</div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} className='h-1' />
</div>
</div>
{/* Input area (fixed at bottom) */}
<div className='bg-background p-4'>
<div className='mx-auto max-w-3xl'>
<div className='relative rounded-2xl border bg-background shadow-sm'>
<Input
ref={inputRef}
value={copilotMessage}
onChange={(e) => setCopilotMessage(e.target.value)}
onKeyDown={handleKeyPress}
placeholder='Ask about Sim Studio documentation...'
className='min-h-[50px] flex-1 rounded-2xl border-0 bg-transparent py-7 pr-16 pl-6 text-base focus-visible:ring-0 focus-visible:ring-offset-0'
disabled={isLoading}
/>
<Button
onClick={handleSendMessage}
size='icon'
disabled={!copilotMessage.trim() || isLoading}
className='-translate-y-1/2 absolute top-1/2 right-3 h-10 w-10 rounded-xl bg-black p-0 text-white hover:bg-gray-800 dark:bg-primary dark:hover:bg-primary/80'
>
<ArrowUp className='h-4 w-4 dark:text-black' />
</Button>
</div>
<div className='mt-2 text-center text-muted-foreground text-xs'>
<p>Ask questions about Sim Studio documentation and features</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,17 +1,21 @@
'use client'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { Bot, Expand, Loader2, Send, User, X } from 'lucide-react'
import { Bot, Loader2, Send, User } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { createLogger } from '@/lib/logs/console-logger'
import { CopilotModal } from './components/copilot-modal/copilot-modal'
const logger = createLogger('Copilot')
interface CopilotProps {
panelWidth: number
isFullscreen?: boolean
onFullscreenToggle?: (fullscreen: boolean) => void
fullscreenInput?: string
onFullscreenInputChange?: (input: string) => void
}
interface CopilotRef {
@@ -33,11 +37,16 @@ interface Message {
isStreaming?: boolean
}
export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref) => {
export const Copilot = forwardRef<CopilotRef, CopilotProps>(({
panelWidth,
isFullscreen = false,
onFullscreenToggle,
fullscreenInput = '',
onFullscreenInputChange
}, ref) => {
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isFullscreen, setIsFullscreen] = useState(false)
const scrollAreaRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
@@ -334,30 +343,159 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
)
}
// Convert messages for modal (role -> type)
const modalMessages = messages.map(msg => ({
id: msg.id,
content: msg.content,
type: msg.role as 'user' | 'assistant',
timestamp: msg.timestamp,
citations: msg.sources?.map((source, index) => ({
id: index + 1,
title: source.title,
url: source.link
}))
}))
// Handle modal message sending
const handleModalSendMessage = useCallback(async (message: string) => {
// Use the same handleSubmit logic but with the message parameter
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
content: message,
timestamp: new Date(),
}
const streamingMessage: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
timestamp: new Date(),
isStreaming: true,
}
setMessages((prev) => [...prev, userMessage, streamingMessage])
setIsLoading(true)
try {
logger.info('Sending docs RAG query:', { query: message })
const response = await fetch('/api/docs/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: message,
topK: 5,
stream: true,
}),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`)
}
// Handle streaming response
if (response.headers.get('content-type')?.includes('text/event-stream')) {
const reader = response.body?.getReader()
const decoder = new TextDecoder()
let accumulatedContent = ''
let sources: any[] = []
if (!reader) {
throw new Error('Failed to get response reader')
}
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6))
if (data.type === 'metadata') {
sources = data.sources || []
} else if (data.type === 'content') {
accumulatedContent += data.content
// Update the streaming message with accumulated content
setMessages((prev) =>
prev.map((msg) =>
msg.id === streamingMessage.id
? { ...msg, content: accumulatedContent, sources }
: msg
)
)
} else if (data.type === 'done') {
// Finish streaming
setMessages((prev) =>
prev.map((msg) =>
msg.id === streamingMessage.id
? { ...msg, isStreaming: false, sources }
: msg
)
)
} else if (data.type === 'error') {
throw new Error(data.error || 'Streaming error')
}
} catch (parseError) {
logger.warn('Failed to parse SSE data:', parseError)
}
}
}
}
logger.info('Received docs RAG response:', {
contentLength: accumulatedContent.length,
sourcesCount: sources.length,
})
} else {
// Fallback to non-streaming response
const data = await response.json()
const assistantMessage: Message = {
id: streamingMessage.id,
role: 'assistant',
content: data.response || 'Sorry, I could not generate a response.',
timestamp: new Date(),
sources: data.sources || [],
isStreaming: false,
}
setMessages((prev) => prev.slice(0, -1).concat(assistantMessage))
}
} catch (error) {
logger.error('Docs RAG error:', error)
const errorMessage: Message = {
id: streamingMessage.id,
role: 'assistant',
content:
'Sorry, I encountered an error while searching the documentation. Please try again.',
timestamp: new Date(),
isStreaming: false,
}
setMessages((prev) => prev.slice(0, -1).concat(errorMessage))
} finally {
setIsLoading(false)
}
}, [])
return (
<>
{/* Main Panel Content */}
<div className='flex h-full flex-col'>
{/* Header */}
<div className='border-b p-4'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Bot className='h-5 w-5 text-primary' />
<div>
<h3 className='font-medium text-sm'>Documentation Copilot</h3>
<p className='text-muted-foreground text-xs'>Ask questions about Sim Studio</p>
</div>
</div>
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='icon'
onClick={() => setIsFullscreen(true)}
className='h-8 w-8'
title='Expand'
>
<Expand className='h-4 w-4' />
</Button>
<div className='flex items-center gap-2'>
<Bot className='h-5 w-5 text-primary' />
<div>
<h3 className='font-medium text-sm'>Documentation Copilot</h3>
<p className='text-muted-foreground text-xs'>Ask questions about Sim Studio</p>
</div>
</div>
</div>
@@ -420,95 +558,15 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
</div>
{/* Fullscreen Modal */}
{isFullscreen && (
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
<DialogContent className='h-[80vh] w-full max-w-4xl p-0'>
<div className='flex h-full flex-col'>
{/* Header */}
<div className='border-b p-4'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Bot className='h-5 w-5 text-primary' />
<div>
<h3 className='font-medium text-sm'>Documentation Copilot</h3>
<p className='text-muted-foreground text-xs'>
Ask questions about Sim Studio
</p>
</div>
</div>
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='icon'
onClick={() => setIsFullscreen(false)}
className='h-8 w-8'
title='Close'
>
<X className='h-4 w-4' />
</Button>
</div>
</div>
</div>
{/* Messages */}
<ScrollArea className='flex-1'>
{messages.length === 0 ? (
<div className='flex h-full flex-col items-center justify-center p-8 text-center'>
<Bot className='mb-4 h-12 w-12 text-muted-foreground' />
<h3 className='mb-2 font-medium text-sm'>Welcome to Documentation Copilot</h3>
<p className='mb-4 max-w-xs text-muted-foreground text-xs'>
Ask me anything about Sim Studio features, workflows, tools, or how to get
started.
</p>
<div className='space-y-2 text-left'>
<div className='text-muted-foreground text-xs'>Try asking:</div>
<div className='space-y-1'>
<div className='rounded bg-muted/50 px-2 py-1 text-xs'>
"How do I create a workflow?"
</div>
<div className='rounded bg-muted/50 px-2 py-1 text-xs'>
"What tools are available?"
</div>
<div className='rounded bg-muted/50 px-2 py-1 text-xs'>
"How do I deploy my workflow?"
</div>
</div>
</div>
</div>
) : (
<div className='space-y-1'>{messages.map(renderMessage)}</div>
)}
</ScrollArea>
{/* Input */}
<div className='border-t p-4'>
<form onSubmit={handleSubmit} className='flex gap-2'>
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder='Ask about Sim Studio documentation...'
disabled={isLoading}
className='flex-1'
autoComplete='off'
/>
<Button
type='submit'
size='icon'
disabled={!input.trim() || isLoading}
className='h-10 w-10'
>
{isLoading ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : (
<Send className='h-4 w-4' />
)}
</Button>
</form>
</div>
</div>
</DialogContent>
</Dialog>
)}
<CopilotModal
open={isFullscreen}
onOpenChange={(open) => onFullscreenToggle?.(open)}
copilotMessage={fullscreenInput}
setCopilotMessage={(message) => onFullscreenInputChange?.(message)}
messages={modalMessages}
onSendMessage={handleModalSendMessage}
isLoading={isLoading}
/>
</>
)
})

View File

@@ -17,7 +17,9 @@ export function Panel() {
const [width, setWidth] = useState(336) // 84 * 4 = 336px (default width)
const [isDragging, setIsDragging] = useState(false)
const [chatMessage, setChatMessage] = useState<string>('')
const [copilotMessage, setCopilotMessage] = useState<string>('')
const [isChatModalOpen, setIsChatModalOpen] = useState(false)
const [isCopilotModalOpen, setIsCopilotModalOpen] = useState(false)
const copilotRef = useRef<{ clearMessages: () => void }>(null)
const isOpen = usePanelStore((state) => state.isOpen)
@@ -155,7 +157,14 @@ export function Panel() {
) : activeTab === 'console' ? (
<Console panelWidth={width} />
) : activeTab === 'copilot' ? (
<Copilot ref={copilotRef} panelWidth={width} />
<Copilot
ref={copilotRef}
panelWidth={width}
isFullscreen={isCopilotModalOpen}
onFullscreenToggle={setIsCopilotModalOpen}
fullscreenInput={copilotMessage}
onFullscreenInputChange={setCopilotMessage}
/>
) : (
<Variables panelWidth={width} />
)}
@@ -190,6 +199,21 @@ export function Panel() {
<TooltipContent side='left'>Expand Chat</TooltipContent>
</Tooltip>
)}
{activeTab === 'copilot' && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setIsCopilotModalOpen(true)}
className='flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground'
>
<Expand className='h-5 w-5' />
<span className='sr-only'>Expand Copilot</span>
</button>
</TooltipTrigger>
<TooltipContent side='left'>Expand Copilot</TooltipContent>
</Tooltip>
)}
</div>
</div>