/ 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  }