/ 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                &ldquo;{q}&rdquo;
 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">&ldquo;{message.content}&rdquo;</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  }