/ components / SlideChatSheet.tsx
SlideChatSheet.tsx
1 import { useState, useRef, useEffect, useCallback } from 'react'; 2 import { Send, Loader2, MessageSquare, Globe } from 'lucide-react'; 3 import { Button } from '@/components/ui/button'; 4 import { Markdown } from '@/components/Markdown'; 5 import type { ChatMessage } from '@/lib/use-slide-chat'; 6 7 const MIN_WIDTH = 300; 8 const MAX_WIDTH = 700; 9 const DEFAULT_WIDTH = 420; 10 11 interface Props { 12 open: boolean; 13 onOpenChange: (open: boolean) => void; 14 slideTitle: string; 15 reviewFocus: string | null; 16 messages: ChatMessage[]; 17 isStreaming: boolean; 18 onSend: (text: string) => void; 19 } 20 21 function SuggestedQuestions({ reviewFocus, onSelect }: { reviewFocus: string | null; onSelect: (q: string) => void }) { 22 const suggestions = buildSuggestions(reviewFocus); 23 if (suggestions.length === 0) return null; 24 25 return ( 26 <div className="flex-1 flex flex-col items-center justify-center gap-4 px-4"> 27 <p className="text-sm text-muted-foreground">Suggested questions:</p> 28 <div className="flex flex-col gap-2 w-full max-w-sm"> 29 {suggestions.map((q, i) => ( 30 <button 31 key={i} 32 onClick={() => onSelect(q)} 33 className="text-left text-sm px-3 py-2 rounded-md border border-border hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground" 34 > 35 {q} 36 </button> 37 ))} 38 </div> 39 </div> 40 ); 41 } 42 43 function buildSuggestions(reviewFocus: string | null): string[] { 44 const suggestions: string[] = []; 45 const lower = (reviewFocus ?? '').toLowerCase(); 46 47 if (lower.includes('error') || lower.includes('edge case') || lower.includes('validation')) { 48 suggestions.push('What edge cases could break this code?'); 49 } 50 if (lower.includes('performance') || lower.includes('scaling')) { 51 suggestions.push('Are there any performance concerns here?'); 52 } 53 if (lower.includes('security') || lower.includes('auth')) { 54 suggestions.push('Are there security implications to review?'); 55 } 56 57 suggestions.push('Why were these changes made this way?'); 58 if (suggestions.length < 3) { 59 suggestions.push('What could go wrong with this approach?'); 60 } 61 62 return suggestions.slice(0, 3); 63 } 64 65 function MessageBubble({ message }: { message: ChatMessage }) { 66 if (message.role === 'user') { 67 return ( 68 <div className="flex justify-end"> 69 <div className="bg-primary text-primary-foreground rounded-lg px-3 py-2 max-w-[85%] text-sm"> 70 {message.content} 71 </div> 72 </div> 73 ); 74 } 75 76 return ( 77 <div className="flex justify-start"> 78 <div className="bg-muted rounded-lg px-3 py-2 max-w-[85%] text-sm"> 79 {message.toolCalls && message.toolCalls.length > 0 && ( 80 <div className="flex flex-wrap gap-1.5 mb-2"> 81 {message.toolCalls.map((tool) => ( 82 <span 83 key={tool} 84 className={`inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/10 text-primary px-2 py-0.5 text-xs ${message.isStreaming ? 'animate-pulse' : ''}`} 85 > 86 <Globe className="h-3 w-3" /> 87 {tool} 88 </span> 89 ))} 90 </div> 91 )} 92 {message.content ? ( 93 <Markdown className="chat-response">{message.content}</Markdown> 94 ) : message.isStreaming ? ( 95 <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> 96 ) : null} 97 {message.isStreaming && message.content && ( 98 <span className="inline-block w-1.5 h-4 bg-foreground/50 animate-pulse ml-0.5 align-text-bottom" /> 99 )} 100 </div> 101 </div> 102 ); 103 } 104 105 export function SlideChatSheet({ open, onOpenChange, slideTitle, reviewFocus, messages, isStreaming, onSend }: Props) { 106 const [input, setInput] = useState(''); 107 const [width, setWidth] = useState(DEFAULT_WIDTH); 108 const messagesEndRef = useRef<HTMLDivElement>(null); 109 const textareaRef = useRef<HTMLTextAreaElement>(null); 110 const dragging = useRef(false); 111 const didDrag = useRef(false); 112 113 const scrollToBottom = useCallback(() => { 114 messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); 115 }, []); 116 117 useEffect(() => { 118 scrollToBottom(); 119 }, [messages, scrollToBottom]); 120 121 useEffect(() => { 122 if (open) { 123 const timer = setTimeout(() => textareaRef.current?.focus(), 300); 124 return () => clearTimeout(timer); 125 } 126 }, [open]); 127 128 // Drag-to-resize: attach to window so dragging works even if cursor leaves the handle 129 useEffect(() => { 130 function onMouseMove(e: MouseEvent) { 131 if (!dragging.current) return; 132 didDrag.current = true; 133 const newWidth = window.innerWidth - e.clientX; 134 setWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, newWidth))); 135 } 136 function onMouseUp() { 137 if (dragging.current) { 138 dragging.current = false; 139 document.body.style.cursor = ''; 140 document.body.style.userSelect = ''; 141 } 142 } 143 window.addEventListener('mousemove', onMouseMove); 144 window.addEventListener('mouseup', onMouseUp); 145 return () => { 146 window.removeEventListener('mousemove', onMouseMove); 147 window.removeEventListener('mouseup', onMouseUp); 148 }; 149 }, []); 150 151 function handleHandleMouseDown(e: React.MouseEvent) { 152 if (!open) return; // only resize when open 153 e.preventDefault(); 154 dragging.current = true; 155 didDrag.current = false; 156 document.body.style.cursor = 'col-resize'; 157 document.body.style.userSelect = 'none'; 158 } 159 160 function handleHandleClick() { 161 // If we just finished a drag, don't toggle 162 if (didDrag.current) { 163 didDrag.current = false; 164 return; 165 } 166 onOpenChange(!open); 167 } 168 169 function handleSend() { 170 const trimmed = input.trim(); 171 if (!trimmed || isStreaming) return; 172 setInput(''); 173 onSend(trimmed); 174 } 175 176 function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) { 177 if (e.key === 'Enter' && !e.shiftKey) { 178 e.preventDefault(); 179 handleSend(); 180 } 181 } 182 183 return ( 184 <div className="shrink-0 flex flex-row h-full"> 185 {/* Handle / toggle bar */} 186 <button 187 type="button" 188 onMouseDown={handleHandleMouseDown} 189 onClick={handleHandleClick} 190 className={`group relative flex items-center justify-center w-5 border-l border-border bg-muted/30 hover:bg-muted/60 transition-colors ${open ? 'cursor-col-resize' : 'cursor-pointer'}`} 191 aria-label={open ? 'Collapse chat panel' : 'Expand chat panel'} 192 > 193 {/* Grip dots when open, chat icon when collapsed */} 194 {open ? ( 195 <div className="flex flex-col gap-1 opacity-40 group-hover:opacity-70 transition-opacity"> 196 <div className="w-1 h-1 rounded-full bg-foreground" /> 197 <div className="w-1 h-1 rounded-full bg-foreground" /> 198 <div className="w-1 h-1 rounded-full bg-foreground" /> 199 </div> 200 ) : ( 201 <MessageSquare className="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground transition-colors" /> 202 )} 203 </button> 204 205 {/* Panel content */} 206 <div className="overflow-hidden transition-[width] duration-300 ease-in-out" style={{ width: open ? width : 0 }}> 207 <div className="h-full flex flex-col bg-background" style={{ minWidth: width }}> 208 {/* Header */} 209 <div className="flex items-center justify-between border-b px-4 py-3"> 210 <div className="min-w-0 flex-1"> 211 <h3 className="text-sm font-semibold">Ask about this slide</h3> 212 <p className="text-xs text-muted-foreground truncate">{slideTitle}</p> 213 </div> 214 </div> 215 216 {/* Message list */} 217 <div className="flex-1 overflow-y-auto min-h-0 px-4 py-3 flex flex-col gap-3"> 218 {messages.length === 0 ? ( 219 <SuggestedQuestions 220 reviewFocus={reviewFocus} 221 onSelect={(q) => { 222 onSend(q); 223 }} 224 /> 225 ) : ( 226 <> 227 {messages.map((msg) => ( 228 <MessageBubble key={msg.id} message={msg} /> 229 ))} 230 <div ref={messagesEndRef} /> 231 </> 232 )} 233 </div> 234 235 {/* Input area */} 236 <div className="border-t p-4 flex gap-2 items-end"> 237 <textarea 238 ref={textareaRef} 239 value={input} 240 onChange={(e) => setInput(e.target.value)} 241 onKeyDown={handleKeyDown} 242 placeholder="Ask a question about this slide..." 243 rows={2} 244 className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" 245 /> 246 <Button size="sm" onClick={handleSend} disabled={isStreaming || !input.trim()} className="shrink-0"> 247 {isStreaming ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />} 248 </Button> 249 </div> 250 </div> 251 </div> 252 </div> 253 ); 254 }