second-brain/CHATJS_BEST_PRACTICES.md

17 KiB

🚀 ChatJS Best Practices - Second Brain Integration Guide

Overview

This document contains advanced patterns from ChatJS that should be integrated into the Second Brain project to achieve production-grade quality.


1. 📜 Smart Scroll Management

Problem: Current implementation scrolls even when user is reading previous messages.

ChatJS Solution: use-scroll-anchor.ts

// hooks/use-scroll-anchor.ts
import { useEffect, useRef, useState } from 'react'

export function useScrollAnchor() {
  const messagesRef = useRef<HTMLDivElement>(null)
  const scrollRef = useRef<HTMLDivElement>(null)
  const isAtBottomRef = useRef(true)
  const [showScrollButton, setShowScrollButton] = useState(false)

  useEffect(() => {
    const messagesEl = messagesRef.current
    const scrollEl = scrollRef.current

    if (!messagesEl || !scrollEl) return

    const handleScroll = () => {
      const { scrollTop, scrollHeight, clientHeight } = scrollEl
      const distanceFromBottom = scrollHeight - scrollTop - clientHeight
      
      // User is at bottom if within 50px
      isAtBottomRef.current = distanceFromBottom < 50
      setShowScrollButton(!isAtBottomRef.current)
    }

    scrollEl.addEventListener('scroll', handleScroll)
    return () => scrollEl.removeEventListener('scroll', handleScroll)
  }, [])

  const scrollToBottom = () => {
    const messagesEl = messagesRef.current
    if (messagesEl) {
      messagesEl.scrollIntoView({ behavior: 'smooth', block: 'end' })
    }
  }

  // Auto-scroll ONLY if user is at bottom
  const autoScroll = () => {
    if (isAtBottomRef.current) {
      scrollToBottom()
    }
  }

  return {
    messagesRef,
    scrollRef,
    showScrollButton,
    scrollToBottom,
    autoScroll,
  }
}

Usage in ChatPage:

export function ChatPage() {
  const { messages, isTyping } = useChatStore()
  const { messagesRef, scrollRef, showScrollButton, scrollToBottom, autoScroll } = useScrollAnchor()

  useEffect(() => {
    autoScroll() // Only scrolls if user is at bottom
  }, [messages, isTyping])

  return (
    <div className="flex flex-col h-full">
      <div ref={scrollRef} className="flex-1 overflow-y-auto">
        <div className="py-8">
          {messages.map(msg => <ChatMessage key={msg.id} message={msg} />)}
          {isTyping && <TypingIndicator />}
          <div ref={messagesRef} />
        </div>
      </div>

      {/* Scroll to Bottom Button */}
      {showScrollButton && (
        <button
          onClick={scrollToBottom}
          className="absolute bottom-24 right-8 p-3 bg-primary rounded-full shadow-lg"
        >
          <ChevronDown className="w-5 h-5 text-white" />
        </button>
      )}

      <ChatInput />
    </div>
  )
}

2. 🎬 Message Actions

ChatJS has excellent message actions on hover.

// components/chat/MessageActions.tsx
import { Copy, RotateCw, Edit, Share, MoreHorizontal } from 'lucide-react'
import { toast } from 'sonner'

interface MessageActionsProps {
  message: Message
  onRegenerate?: () => void
  onEdit?: () => void
}

export function MessageActions({ message, onRegenerate, onEdit }: MessageActionsProps) {
  const copyToClipboard = () => {
    navigator.clipboard.writeText(message.content)
    toast.success('Copied to clipboard')
  }

  return (
    <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
      {/* Copy */}
      <button
        onClick={copyToClipboard}
        className="p-1.5 hover:bg-zinc-800 rounded transition-colors"
        title="Copy"
      >
        <Copy className="w-4 h-4 text-zinc-400" />
      </button>

      {/* Regenerate (AI messages only) */}
      {message.role === 'assistant' && onRegenerate && (
        <button
          onClick={onRegenerate}
          className="p-1.5 hover:bg-zinc-800 rounded transition-colors"
          title="Regenerate"
        >
          <RotateCw className="w-4 h-4 text-zinc-400" />
        </button>
      )}

      {/* Edit (User messages only) */}
      {message.role === 'user' && onEdit && (
        <button
          onClick={onEdit}
          className="p-1.5 hover:bg-zinc-800 rounded transition-colors"
          title="Edit"
        >
          <Edit className="w-4 h-4 text-zinc-400" />
        </button>
      )}

      {/* Share */}
      <button
        className="p-1.5 hover:bg-zinc-800 rounded transition-colors"
        title="Share"
      >
        <Share className="w-4 h-4 text-zinc-400" />
      </button>
    </div>
  )
}

Update ChatMessage to include actions:

export function ChatMessage({ message }: ChatMessageProps) {
  const { regenerateMessage } = useChatStore()
  const isUser = message.role === 'user'

  return (
    <div className="group flex gap-3 max-w-4xl px-6 py-4 relative">
      {/* Avatar */}
      <div className={/* ... */}>
        {/* ... */}
      </div>

      {/* Content */}
      <div className="flex-1 flex flex-col gap-2">
        {/* ... existing content ... */}
      </div>

      {/* Actions - appears on hover */}
      <MessageActions
        message={message}
        onRegenerate={() => regenerateMessage(message.id)}
      />
    </div>
  )
}

3. 📎 Advanced File Upload

ChatJS has excellent drag & drop with preview.

// components/chat/FileUploadZone.tsx
import { useState } from 'react'
import { Upload, X, File, Image } from 'lucide-react'

interface FileUploadZoneProps {
  onFilesSelect: (files: File[]) => void
  selectedFiles: File[]
  onFileRemove: (index: number) => void
}

export function FileUploadZone({ onFilesSelect, selectedFiles, onFileRemove }: FileUploadZoneProps) {
  const [isDragging, setIsDragging] = useState(false)

  const handleDrop = (e: React.DragEvent) => {
    e.preventDefault()
    setIsDragging(false)
    
    const files = Array.from(e.dataTransfer.files)
    onFilesSelect(files)
  }

  const handleDragOver = (e: React.DragEvent) => {
    e.preventDefault()
    setIsDragging(true)
  }

  const handleDragLeave = () => {
    setIsDragging(false)
  }

  return (
    <div>
      {/* Drag & Drop Zone */}
      <div
        onDrop={handleDrop}
        onDragOver={handleDragOver}
        onDragLeave={handleDragLeave}
        className={`
          border-2 border-dashed rounded-lg p-8 transition-all
          ${isDragging 
            ? 'border-primary bg-primary/5' 
            : 'border-zinc-700 hover:border-zinc-600'
          }
        `}
      >
        <div className="flex flex-col items-center gap-2 text-center">
          <Upload className={`w-8 h-8 ${isDragging ? 'text-primary' : 'text-zinc-500'}`} />
          <p className="text-sm text-zinc-400">
            {isDragging ? 'Drop files here' : 'Drag files here or click to browse'}
          </p>
          <p className="text-xs text-zinc-600">
            Supports: Images, PDFs, Documents (max 10MB)
          </p>
        </div>
      </div>

      {/* File Previews */}
      {selectedFiles.length > 0 && (
        <div className="mt-4 space-y-2">
          {selectedFiles.map((file, index) => (
            <FilePreviewCard
              key={index}
              file={file}
              onRemove={() => onFileRemove(index)}
            />
          ))}
        </div>
      )}
    </div>
  )
}

// File Preview Card
function FilePreviewCard({ file, onRemove }: { file: File; onRemove: () => void }) {
  const isImage = file.type.startsWith('image/')
  const [preview, setPreview] = useState<string | null>(null)

  useEffect(() => {
    if (isImage) {
      const reader = new FileReader()
      reader.onloadend = () => setPreview(reader.result as string)
      reader.readAsDataURL(file)
    }
  }, [file, isImage])

  return (
    <div className="flex items-center gap-3 p-3 bg-zinc-900 border border-zinc-800 rounded-lg">
      {/* Icon or Image Preview */}
      {isImage && preview ? (
        <img src={preview} alt={file.name} className="w-12 h-12 object-cover rounded" />
      ) : (
        <div className="w-12 h-12 bg-zinc-800 rounded flex items-center justify-center">
          <File className="w-6 h-6 text-zinc-500" />
        </div>
      )}

      {/* File Info */}
      <div className="flex-1 min-w-0">
        <p className="text-sm font-medium text-zinc-200 truncate">{file.name}</p>
        <p className="text-xs text-zinc-500">{formatFileSize(file.size)}</p>
      </div>

      {/* Remove Button */}
      <button
        onClick={onRemove}
        className="p-1 hover:bg-zinc-800 rounded transition-colors"
      >
        <X className="w-4 h-4 text-zinc-500" />
      </button>
    </div>
  )
}

function formatFileSize(bytes: number): string {
  if (bytes < 1024) return bytes + ' B'
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
  return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}

4. ⌨️ Advanced Keyboard Shortcuts

ChatJS has a keyboard shortcuts modal.

// components/KeyboardShortcutsModal.tsx
import { useEffect, useState } from 'react'

export function KeyboardShortcutsModal() {
  const [isOpen, setIsOpen] = useState(false)

  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      // Cmd+/ or Ctrl+/ to toggle
      if ((e.metaKey || e.ctrlKey) && e.key === '/') {
        e.preventDefault()
        setIsOpen(prev => !prev)
      }
    }

    window.addEventListener('keydown', handleKeyDown)
    return () => window.removeEventListener('keydown', handleKeyDown)
  }, [])

  if (!isOpen) return null

  const shortcuts = [
    { keys: ['Cmd', 'N'], action: 'New chat' },
    { keys: ['Cmd', 'K'], action: 'Search conversations' },
    { keys: ['Cmd', 'L'], action: 'Go to library' },
    { keys: ['Cmd', 'M'], action: 'Go to neural map' },
    { keys: ['Cmd', '/'], action: 'Show keyboard shortcuts' },
    { keys: ['Escape'], action: 'Close modal' },
    { keys: ['Cmd', 'Enter'], action: 'Send message' },
  ]

  return (
    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
      <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 max-w-md w-full">
        <h2 className="text-xl font-bold text-zinc-100 mb-4">
          Keyboard Shortcuts
        </h2>

        <div className="space-y-2">
          {shortcuts.map((shortcut, i) => (
            <div key={i} className="flex items-center justify-between py-2">
              <span className="text-sm text-zinc-400">{shortcut.action}</span>
              <div className="flex gap-1">
                {shortcut.keys.map((key, j) => (
                  <kbd
                    key={j}
                    className="px-2 py-1 bg-zinc-800 border border-zinc-700 rounded text-xs font-mono text-zinc-300"
                  >
                    {key}
                  </kbd>
                ))}
              </div>
            </div>
          ))}
        </div>

        <button
          onClick={() => setIsOpen(false)}
          className="mt-6 w-full px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
        >
          Close
        </button>
      </div>
    </div>
  )
}

5. 🔄 Regenerate Message

Add to chatStore.ts:

interface ChatState {
  messages: Message[]
  isTyping: boolean
  sendMessage: (params: SendMessageParams) => Promise<void>
  regenerateMessage: (messageId: string) => Promise<void> // NEW
  clearMessages: () => void
}

export const useChatStore = create<ChatState>((set, get) => ({
  // ... existing code ...

  regenerateMessage: async (messageId: string) => {
    const { messages } = get()
    const messageIndex = messages.findIndex(m => m.id === messageId)
    
    if (messageIndex === -1) return

    // Find the last user message before this AI message
    const userMessage = messages
      .slice(0, messageIndex)
      .reverse()
      .find(m => m.role === 'user')

    if (!userMessage) return

    // Remove the AI message and everything after it
    set({ messages: messages.slice(0, messageIndex) })

    // Regenerate from user message
    set({ isTyping: true })

    try {
      const res = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          message: userMessage.content,
          // Use same model/rag settings as original
        }),
      })

      const data = await res.json()

      const newMessage: Message = {
        id: Date.now().toString(),
        role: 'assistant',
        content: data.answer,
        timestamp: new Date(),
        sources: data.sources,
      }

      set(state => ({ messages: [...state.messages, newMessage] }))
    } catch (err) {
      console.error('Regenerate error:', err)
    } finally {
      set({ isTyping: false })
    }
  },
}))

6. 📱 Mobile Improvements

ChatJS has excellent mobile UX.

Mobile-Specific Improvements:

// components/chat/MobileOptimizations.tsx

// 1. Virtual Keyboard Handling
useEffect(() => {
  const handleResize = () => {
    // Adjust viewport when keyboard opens
    const vh = window.innerHeight * 0.01
    document.documentElement.style.setProperty('--vh', `${vh}px`)
  }

  window.addEventListener('resize', handleResize)
  handleResize()

  return () => window.removeEventListener('resize', handleResize)
}, [])

// 2. Touch Gestures for Sidebar
const handleSwipe = (e: TouchEvent) => {
  // Swipe from left edge to open sidebar
  if (e.touches[0].clientX < 20) {
    openSidebar()
  }
}

// 3. Prevent Zoom on Double-Tap
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />

// 4. Safe Area Insets (for iPhone notch)
<div className="pb-[env(safe-area-inset-bottom)]">
  <ChatInput />
</div>

7. 🎨 Loading States

ChatJS has skeleton loaders everywhere.

// components/ui/MessageSkeleton.tsx
export function MessageSkeleton() {
  return (
    <div className="flex gap-3 max-w-4xl px-6 py-4 animate-pulse">
      <div className="w-8 h-8 rounded-full bg-zinc-800" />
      <div className="flex-1 space-y-2">
        <div className="h-3 bg-zinc-800 rounded w-1/4" />
        <div className="h-4 bg-zinc-800 rounded w-3/4" />
        <div className="h-4 bg-zinc-800 rounded w-2/3" />
      </div>
    </div>
  )
}

// Usage
{isLoading ? (
  <>
    <MessageSkeleton />
    <MessageSkeleton />
  </>
) : (
  messages.map(msg => <ChatMessage key={msg.id} message={msg} />)
)}

8. 🔔 Better Error Handling

ChatJS shows user-friendly errors.

// lib/error-handler.ts
export function handleChatError(error: unknown) {
  console.error('Chat error:', error)

  if (error instanceof Error) {
    // Network errors
    if (error.message.includes('Failed to fetch')) {
      toast.error('Connection lost. Check your internet.')
      return
    }

    // API errors
    if (error.message.includes('429')) {
      toast.error('Rate limit exceeded. Please wait a moment.')
      return
    }

    if (error.message.includes('401')) {
      toast.error('Session expired. Please log in again.')
      return
    }
  }

  // Generic error
  toast.error('Something went wrong. Please try again.')
}

// Usage in chatStore
catch (err) {
  handleChatError(err)
}

9. 💾 Local Storage Persistence

ChatJS saves draft messages.

// hooks/useLocalStorage.ts
export function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key)
      return item ? JSON.parse(item) : initialValue
    } catch (error) {
      console.error(error)
      return initialValue
    }
  })

  const setValue = (value: T | ((val: T) => T)) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value
      setStoredValue(valueToStore)
      window.localStorage.setItem(key, JSON.stringify(valueToStore))
    } catch (error) {
      console.error(error)
    }
  }

  return [storedValue, setValue] as const
}

// Usage in ChatInput
export function ChatInput() {
  const [draftInput, setDraftInput] = useLocalStorage('chat-draft', '')

  const handleSend = () => {
    // ... send logic
    setDraftInput('') // Clear draft
  }

  return (
    <textarea
      value={draftInput}
      onChange={(e) => setDraftInput(e.target.value)}
    />
  )
}

10. 🎯 Implementation Priority

For Second Brain, implement in this order:

  1. Smart Scroll (useScrollAnchor) - Immediate UX improvement
  2. Message Actions (Copy, Regenerate) - Professional feel
  3. Better File Upload (Drag & drop with preview) - Core feature
  4. Keyboard Shortcuts Modal - Power user feature
  5. Loading Skeletons - Polish
  6. Error Handling - Reliability
  7. Draft Persistence - Nice to have
  8. Mobile Optimizations - Once desktop is solid

📝 Summary

ChatJS demonstrates production-grade patterns that Second Brain should adopt:

  • Smart scrolling prevents annoying auto-scroll
  • Message actions make the chat feel professional
  • File previews improve upload UX dramatically
  • Keyboard shortcuts delight power users
  • Error handling builds trust
  • Loading states feel responsive

The key insight: Small details compound into a delightful experience.


🔗 Resources

Use this as reference when polishing Second Brain! 🚀