/ components / SlideChatSheet.tsx
SlideChatSheet.tsx
1 import { useState, useRef, useEffect, useCallback } from 'react'; 2 import { Send, Loader2, MessageSquare } from 'lucide-react'; 3 import { Markdown } from '@/components/Markdown'; 4 import type { ChatMessage } from '@/lib/use-slide-chat'; 5 6 const MIN_WIDTH = 300; 7 const MAX_WIDTH = 700; 8 const DEFAULT_WIDTH = 460; 9 10 interface Props { 11 open: boolean; 12 onOpenChange: (open: boolean) => void; 13 slideTitle: string; 14 reviewFocus: string | null; 15 messages: ChatMessage[]; 16 isStreaming: boolean; 17 onSend: (text: string) => void; 18 // When the user selects code in the diff and clicks "Ask about 19 // this", the selected code is passed here as a fenced code block. 20 // The chat sheet renders it as a quoted block above the input and 21 // includes it in the message when the user sends. Cleared after 22 // first send via onQuotedCodeConsumed. 23 quotedCode?: string | null; 24 onQuotedCodeConsumed?: () => void; 25 } 26 27 // Suggested questions on first open. No bordered cards, no bg fills — 28 // just a quiet list of italic prompts under a small label, like the 29 // "you might also like" footer of a printed essay. 30 function SuggestedQuestions({ reviewFocus, onSelect }: { reviewFocus: string | null; onSelect: (q: string) => void }) { 31 const suggestions = buildSuggestions(reviewFocus); 32 if (suggestions.length === 0) return null; 33 34 return ( 35 <div className="flex-1 flex flex-col items-start justify-center gap-4 px-2 max-w-md"> 36 <p className="slide-meta">Try asking</p> 37 <ul className="flex flex-col gap-3 w-full"> 38 {suggestions.map((q, i) => ( 39 <li key={i}> 40 <button 41 onClick={() => onSelect(q)} 42 className="text-left font-serif text-base leading-snug text-foreground/75 hover:text-foreground italic transition-colors" 43 > 44 “{q}” 45 </button> 46 </li> 47 ))} 48 </ul> 49 </div> 50 ); 51 } 52 53 function buildSuggestions(reviewFocus: string | null): string[] { 54 const suggestions: string[] = []; 55 const lower = (reviewFocus ?? '').toLowerCase(); 56 57 if (lower.includes('error') || lower.includes('edge case') || lower.includes('validation')) { 58 suggestions.push('What edge cases could break this code?'); 59 } 60 if (lower.includes('performance') || lower.includes('scaling')) { 61 suggestions.push('Are there any performance concerns here?'); 62 } 63 if (lower.includes('security') || lower.includes('auth')) { 64 suggestions.push('Are there security implications to review?'); 65 } 66 67 suggestions.push('Why were these changes made this way?'); 68 if (suggestions.length < 3) { 69 suggestions.push('What could go wrong with this approach?'); 70 } 71 72 return suggestions.slice(0, 3); 73 } 74 75 // Message — no bubble, no bg fill. User questions are a small mono 76 // "You · " label followed by the question as quoted serif italic. 77 // Assistant replies are plain prose. Tool calls are quiet inline 78 // margin notes, not glowing pills. 79 function Message({ message }: { message: ChatMessage }) { 80 if (message.role === 'user') { 81 return ( 82 <div className="flex flex-col gap-1.5"> 83 <span className="slide-meta">You</span> 84 <p className="font-serif text-base leading-snug text-foreground italic">“{message.content}”</p> 85 </div> 86 ); 87 } 88 89 return ( 90 <div className="flex flex-col gap-2"> 91 <span className="slide-meta">Gnosis</span> 92 {message.toolCalls && message.toolCalls.length > 0 && ( 93 <ul className="flex flex-col gap-0.5"> 94 {message.toolCalls.map((tool) => ( 95 <li key={tool} className="slide-meta opacity-70"> 96 · {tool} 97 </li> 98 ))} 99 </ul> 100 )} 101 {message.content ? ( 102 <Markdown className="text-sm text-foreground/85 leading-relaxed">{message.content}</Markdown> 103 ) : message.isStreaming ? ( 104 <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" /> 105 ) : null} 106 {message.isStreaming && message.content && ( 107 <span className="inline-block w-px h-4 bg-foreground/40 align-text-bottom" /> 108 )} 109 </div> 110 ); 111 } 112 113 export function SlideChatSheet({ 114 open, 115 onOpenChange, 116 slideTitle, 117 reviewFocus, 118 messages, 119 isStreaming, 120 onSend, 121 quotedCode, 122 onQuotedCodeConsumed, 123 }: Props) { 124 const [input, setInput] = useState(''); 125 const [width, setWidth] = useState(DEFAULT_WIDTH); 126 const messagesEndRef = useRef<HTMLDivElement>(null); 127 const textareaRef = useRef<HTMLTextAreaElement>(null); 128 const dragging = useRef(false); 129 const didDrag = useRef(false); 130 131 const scrollToBottom = useCallback(() => { 132 messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); 133 }, []); 134 135 useEffect(() => { 136 scrollToBottom(); 137 }, [messages, scrollToBottom]); 138 139 useEffect(() => { 140 if (open) { 141 const timer = setTimeout(() => textareaRef.current?.focus(), 300); 142 return () => clearTimeout(timer); 143 } 144 }, [open]); 145 146 // Drag-to-resize: attach to window so dragging works even if cursor leaves the handle 147 useEffect(() => { 148 function onMouseMove(e: MouseEvent) { 149 if (!dragging.current) return; 150 didDrag.current = true; 151 const newWidth = window.innerWidth - e.clientX; 152 setWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, newWidth))); 153 } 154 function onMouseUp() { 155 if (dragging.current) { 156 dragging.current = false; 157 document.body.style.cursor = ''; 158 document.body.style.userSelect = ''; 159 } 160 } 161 window.addEventListener('mousemove', onMouseMove); 162 window.addEventListener('mouseup', onMouseUp); 163 return () => { 164 window.removeEventListener('mousemove', onMouseMove); 165 window.removeEventListener('mouseup', onMouseUp); 166 }; 167 }, []); 168 169 function handleHandleMouseDown(e: React.MouseEvent) { 170 if (!open) return; // only resize when open 171 e.preventDefault(); 172 dragging.current = true; 173 didDrag.current = false; 174 document.body.style.cursor = 'col-resize'; 175 document.body.style.userSelect = 'none'; 176 } 177 178 function handleHandleClick() { 179 if (didDrag.current) { 180 didDrag.current = false; 181 return; 182 } 183 onOpenChange(!open); 184 } 185 186 function handleSend() { 187 const trimmed = input.trim(); 188 if (!trimmed || isStreaming) return; 189 // If there's quoted code from a selection, prepend it as a fenced 190 // code block so the AI knows exactly which code the user is asking 191 // about. Clear the quote after sending so it doesn't persist. 192 const message = quotedCode 193 ? `Regarding this code:\n\`\`\`\n${quotedCode}\n\`\`\`\n\n${trimmed}` 194 : trimmed; 195 setInput(''); 196 onQuotedCodeConsumed?.(); 197 onSend(message); 198 } 199 200 function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) { 201 if (e.key === 'Enter' && !e.shiftKey) { 202 e.preventDefault(); 203 handleSend(); 204 } 205 } 206 207 return ( 208 <div className="shrink-0 flex flex-row h-full"> 209 {/* Handle / toggle bar — hairline divider, no fill. */} 210 <button 211 type="button" 212 onMouseDown={handleHandleMouseDown} 213 onClick={handleHandleClick} 214 className={`group relative flex items-center justify-center w-4 border-l border-border hover:bg-muted/30 transition-colors ${open ? 'cursor-col-resize' : 'cursor-pointer'}`} 215 aria-label={open ? 'Collapse chat panel' : 'Expand chat panel'} 216 > 217 {open ? ( 218 <div className="flex flex-col gap-1 opacity-30 group-hover:opacity-60 transition-opacity"> 219 <div className="w-px h-1 bg-foreground" /> 220 <div className="w-px h-1 bg-foreground" /> 221 <div className="w-px h-1 bg-foreground" /> 222 </div> 223 ) : ( 224 <MessageSquare className="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground transition-colors" /> 225 )} 226 </button> 227 228 {/* Panel content */} 229 <div 230 className="overflow-hidden transition-[width] duration-300 ease-out" 231 style={{ width: open ? width : 0 }} 232 > 233 <div className="h-full flex flex-col bg-background" style={{ minWidth: width }}> 234 {/* Header — editorial heading + slide context as 235 meta. Reads like the running header of an essay. */} 236 <div className="border-b border-border px-6 py-4"> 237 <h3 className="editorial-heading text-base">Ask about this slide</h3> 238 <p className="slide-meta truncate mt-0.5">{slideTitle}</p> 239 </div> 240 241 {/* Message list */} 242 <div className="flex-1 overflow-y-auto min-h-0 px-6 py-6 flex flex-col gap-7"> 243 {messages.length === 0 ? ( 244 <SuggestedQuestions reviewFocus={reviewFocus} onSelect={(q) => onSend(q)} /> 245 ) : ( 246 <> 247 {messages.map((msg) => ( 248 <Message key={msg.id} message={msg} /> 249 ))} 250 <div ref={messagesEndRef} /> 251 </> 252 )} 253 </div> 254 255 {/* Input area — bottom-bordered textarea, no rounded 256 fill. Send button is a quiet icon, not a primary CTA. 257 When quoted code is attached from a selection, a quiet 258 preview sits above the input so the user sees what's 259 going to be sent. */} 260 <div className="border-t border-border px-6 py-4 flex flex-col gap-2"> 261 {quotedCode && ( 262 <div className="flex items-start gap-2 text-xs animate-fade-in"> 263 <pre className="flex-1 font-mono text-muted-foreground bg-muted/50 rounded px-2 py-1.5 max-h-20 overflow-y-auto whitespace-pre-wrap break-all"> 264 {quotedCode.length > 200 ? quotedCode.slice(0, 200) + '…' : quotedCode} 265 </pre> 266 <button 267 onClick={() => onQuotedCodeConsumed?.()} 268 className="shrink-0 text-muted-foreground hover:text-foreground transition-colors mt-1" 269 aria-label="Remove quoted code" 270 > 271 × 272 </button> 273 </div> 274 )} 275 <div className="flex gap-3 items-end"> 276 <textarea 277 ref={textareaRef} 278 value={input} 279 onChange={(e) => setInput(e.target.value)} 280 onKeyDown={handleKeyDown} 281 placeholder="Ask a question about this slide…" 282 rows={2} 283 className="flex-1 resize-none bg-transparent border-0 border-b border-border px-0 py-2 text-sm placeholder:text-muted-foreground/60 transition-colors" 284 /> 285 <button 286 onClick={handleSend} 287 disabled={isStreaming || !input.trim()} 288 className="shrink-0 p-2 text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" 289 aria-label="Send" 290 > 291 {isStreaming ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />} 292 </button> 293 </div> 294 </div> 295 </div> 296 </div> 297 </div> 298 ); 299 }