second-brain/JULES_STITCH_PROMPT.md

34 KiB

🧠 Second Brain Frontend Rebuild - Jules + Stitch Prompt

Mission Brief

You are Jules, working with Stitch to rebuild the Second Brain frontend application. The existing code has critical UX/UI issues that make it feel unprofessional. Your job is to transform it into a production-grade chat/RAG interface matching the quality of ChatGPT, Claude, and Perplexity.


🎯 Repository Information

Current Codebase: Uploaded ZIP file contains existing project
Target: https://github.com/DFFM-maker/second-brain

Project Structure:

second-brain/
├── client/          ← FOCUS HERE (frontend)
└── server/          ← DON'T TOUCH (backend works fine)

📊 Current State Analysis

Tech Stack (Already Configured)

  • React 19.2.0 + Vite 7.2.4
  • TypeScript 5.9.3 (strict mode)
  • Tailwind CSS 3.4.19
  • React Router DOM 7.13.0
  • clsx + tailwind-merge

What's Working

Base layout with sidebar
Chat input positioned at bottom
API integration functional
Dark mode theme configured
Basic routing setup

Critical Problems Found

ChatPage.tsx (lines 1-160):

  • No animated typing indicator (just spinning icon)
  • Zero timestamps on messages
  • Plain text rendering (no markdown support)
  • Poor User vs AI visual distinction
  • Native <select> for models (ugly)
  • No file upload capability

Sidebar.tsx (lines 135-163):

  • "Recents" and "History" sections always empty
  • No empty state designs (looks broken)
  • Mobile menu exists but needs improvement

Missing Entirely:

  • Error boundaries
  • Loading skeletons
  • Toast notifications
  • Keyboard shortcuts
  • Proper state management

🚀 Implementation Plan

Phase 1: Dependencies (5 min)

Install required packages:

npm install lucide-react zustand axios react-hook-form zod sonner framer-motion react-markdown remark-gfm react-syntax-highlighter reactflow recharts @types/react-syntax-highlighter -D

Phase 2: Folder Structure (5 min)

Create new directories:

mkdir -p src/hooks src/stores src/types

Expected structure:

src/
├── components/
│   ├── chat/
│   │   ├── ChatInput.tsx          ✅ EXISTS - needs file upload
│   │   ├── ChatMessage.tsx        ✅ EXISTS - needs markdown
│   │   ├── ContextSidebar.tsx     ✅ EXISTS
│   │   ├── TypingIndicator.tsx    🆕 CREATE
│   │   ├── ModelSelector.tsx      🆕 CREATE
│   │   ├── RAGToggle.tsx          🆕 CREATE
│   │   └── MessageMarkdown.tsx    🆕 CREATE
│   ├── layout/
│   │   ├── Header.tsx             ✅ EXISTS
│   │   ├── MainLayout.tsx         ✅ EXISTS
│   │   └── Sidebar.tsx            ✅ EXISTS - add empty states
│   └── ui/
│       ├── Button.tsx             ✅ EXISTS
│       ├── EmptyState.tsx         🆕 CREATE
│       ├── Skeleton.tsx           🆕 CREATE
│       ├── ErrorBoundary.tsx      🆕 CREATE
│       └── Toast.tsx              🆕 CREATE
├── hooks/
│   ├── useChat.ts                 🆕 CREATE
│   ├── useMediaQuery.ts           🆕 CREATE
│   └── useKeyboardShortcuts.ts    🆕 CREATE
├── stores/
│   ├── chatStore.ts               🆕 CREATE
│   └── uiStore.ts                 🆕 CREATE
└── types/
    ├── chat.ts                    🆕 CREATE
    ├── document.ts                🆕 CREATE
    └── api.ts                     🆕 CREATE

💻 Code Implementation

STEP 1: Create Type Definitions

File: src/types/chat.ts

export interface Message {
  id: string
  role: 'user' | 'assistant'
  content: string
  timestamp: Date
  sources?: string[]
}

export interface ChatState {
  messages: Message[]
  isTyping: boolean
  sendMessage: (params: SendMessageParams) => Promise<void>
  clearMessages: () => void
}

export interface SendMessageParams {
  content: string
  files?: File[]
  model: string
  useRag: boolean
}

STEP 2: Create Zustand Store

File: src/stores/chatStore.ts

import { create } from 'zustand'
import { Message, ChatState, SendMessageParams } from '../types/chat'

export const useChatStore = create<ChatState>((set, get) => ({
  messages: [{
    id: '1',
    role: 'assistant',
    content: "Welcome. I've indexed your workspace. What would you like to synthesize today?",
    timestamp: new Date(),
    sources: []
  }],
  isTyping: false,

  sendMessage: async ({ content, files, model, useRag }: SendMessageParams) => {
    // Add user message
    const userMessage: Message = {
      id: Date.now().toString(),
      role: 'user',
      content,
      timestamp: new Date(),
    }
    
    set(state => ({ messages: [...state.messages, userMessage] }))
    set({ isTyping: true })

    try {
      const res = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message: content, model, useRag }),
      })

      if (!res.ok) throw new Error('Chat request failed')

      const data = await res.json()

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

      set(state => ({ messages: [...state.messages, aiMessage] }))
    } catch (err) {
      console.error('Chat error:', err)
      
      const errorMessage: Message = {
        id: (Date.now() + 1).toString(),
        role: 'assistant',
        content: 'Error connecting to brain. Please try again.',
        timestamp: new Date(),
      }
      
      set(state => ({ messages: [...state.messages, errorMessage] }))
    } finally {
      set({ isTyping: false })
    }
  },

  clearMessages: () => set({
    messages: [{
      id: '1',
      role: 'assistant',
      content: "Welcome. I've indexed your workspace. What would you like to synthesize today?",
      timestamp: new Date(),
      sources: []
    }]
  }),
}))

File: src/stores/uiStore.ts

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface UIState {
  sidebarOpen: boolean
  toggleSidebar: () => void
  closeSidebar: () => void
  openSidebar: () => void
}

export const useUIStore = create<UIState>()(
  persist(
    (set) => ({
      sidebarOpen: false,
      toggleSidebar: () => set(state => ({ sidebarOpen: !state.sidebarOpen })),
      closeSidebar: () => set({ sidebarOpen: false }),
      openSidebar: () => set({ sidebarOpen: true }),
    }),
    { name: 'ui-storage' }
  )
)

STEP 3: Create TypingIndicator Component

File: src/components/chat/TypingIndicator.tsx

export function TypingIndicator() {
  return (
    <div className="flex items-start gap-3 px-6 py-4 max-w-4xl">
      <div className="w-8 h-8 rounded-full bg-zinc-800 flex items-center justify-center flex-shrink-0">
        <span className="material-symbols-outlined text-zinc-400 text-sm">smart_toy</span>
      </div>
      <div className="flex flex-col gap-2 mt-1">
        <span className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">
          Assistant
        </span>
        <div className="flex gap-1.5 items-center">
          <div 
            className="w-2 h-2 bg-zinc-600 rounded-full animate-bounce" 
            style={{ animationDelay: '0ms', animationDuration: '1s' }} 
          />
          <div 
            className="w-2 h-2 bg-zinc-600 rounded-full animate-bounce" 
            style={{ animationDelay: '150ms', animationDuration: '1s' }} 
          />
          <div 
            className="w-2 h-2 bg-zinc-600 rounded-full animate-bounce" 
            style={{ animationDelay: '300ms', animationDuration: '1s' }} 
          />
        </div>
      </div>
    </div>
  )
}

STEP 4: Create ModelSelector Component

File: src/components/chat/ModelSelector.tsx

import { useState, useRef, useEffect } from 'react'
import { ChevronDown } from 'lucide-react'
import { cn } from '../../lib/utils'

const OLLAMA_MODELS = [
  'LLAMA3:8B',
  'MINIMAX-M2:1CLOUD',
  'MINICPM-V:LATEST',
  'GLM-4.6:CLOUD',
  'MISTRAL-7B-INSTRUCT-V0.3-Q5_K_M',
  'QWEN2.5-CODER:14B-INSTRUCT-Q4_K_M',
  'QWEN2.5-CODER:7B',
  'NOMIC-EMBED-TEXT:LATEST',
]

interface ModelSelectorProps {
  value: string
  onChange: (model: string) => void
}

export function ModelSelector({ value, onChange }: ModelSelectorProps) {
  const [isOpen, setIsOpen] = useState(false)
  const [models, setModels] = useState<string[]>(OLLAMA_MODELS)
  const dropdownRef = useRef<HTMLDivElement>(null)

  // Fetch models from API
  useEffect(() => {
    fetch('/api/models')
      .then(res => res.json())
      .then(data => {
        if (Array.isArray(data) && data.length > 0) {
          setModels(data)
        }
      })
      .catch(err => console.error('Failed to fetch models:', err))
  }, [])

  // Close on click outside
  useEffect(() => {
    const handleClick = (e: MouseEvent) => {
      if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
        setIsOpen(false)
      }
    }
    document.addEventListener('mousedown', handleClick)
    return () => document.removeEventListener('mousedown', handleClick)
  }, [])

  return (
    <div className="relative" ref={dropdownRef}>
      <button
        onClick={() => setIsOpen(!isOpen)}
        className="flex items-center gap-2 px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg hover:bg-zinc-800 transition-colors text-sm font-medium"
      >
        <span className="material-symbols-outlined text-primary !text-[18px]">psychology</span>
        <span className="text-zinc-200">{value}</span>
        <ChevronDown className={cn(
          "w-4 h-4 text-zinc-400 transition-transform",
          isOpen && "rotate-180"
        )} />
      </button>

      {isOpen && (
        <div className="absolute top-full left-0 mt-2 w-80 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl z-50 max-h-80 overflow-y-auto">
          {models.map((model) => (
            <button
              key={model}
              onClick={() => {
                onChange(model)
                setIsOpen(false)
              }}
              className={cn(
                "w-full px-4 py-2.5 text-left text-sm transition-colors hover:bg-zinc-800",
                value === model && "bg-primary/10 text-primary font-semibold"
              )}
            >
              {model}
            </button>
          ))}
        </div>
      )}
    </div>
  )
}

STEP 5: Create RAGToggle Component

File: src/components/chat/RAGToggle.tsx

import { cn } from '../../lib/utils'

interface RAGToggleProps {
  enabled: boolean
  onChange: (enabled: boolean) => void
}

export function RAGToggle({ enabled, onChange }: RAGToggleProps) {
  return (
    <button
      onClick={() => onChange(!enabled)}
      className={cn(
        "flex items-center gap-2 px-3 py-2 rounded-lg border transition-all text-xs font-bold uppercase tracking-wider",
        enabled
          ? "bg-primary/10 border-primary/30 text-primary"
          : "bg-zinc-900 border-zinc-700 text-zinc-400 hover:bg-zinc-800"
      )}
    >
      <span className="material-symbols-outlined !text-[18px]">
        {enabled ? 'database' : 'chat_bubble'}
      </span>
      <span>{enabled ? 'BRAIN ON' : 'CHAT ONLY'}</span>
    </button>
  )
}

STEP 6: Update ChatMessage Component

File: src/components/chat/ChatMessage.tsx (REPLACE EXISTING)

import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'
import { Message } from '../../types/chat'

interface ChatMessageProps {
  message: Message
}

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

  return (
    <div className={`flex gap-3 max-w-4xl px-6 py-4 ${isUser ? 'ml-auto flex-row-reverse' : 'mr-auto'}`}>
      {/* Avatar */}
      <div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
        isUser ? 'bg-primary' : 'bg-zinc-800'
      }`}>
        <span className="material-symbols-outlined text-white text-sm">
          {isUser ? 'person' : 'smart_toy'}
        </span>
      </div>

      {/* Content */}
      <div className="flex-1 flex flex-col gap-2">
        {/* Header with timestamp */}
        <div className={`flex items-center gap-2 ${isUser ? 'justify-end' : 'justify-start'}`}>
          <span className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">
            {isUser ? 'You' : 'Assistant'}
          </span>
          <span className="text-xs text-zinc-600">
            {formatTimestamp(message.timestamp)}
          </span>
        </div>

        {/* Message bubble */}
        <div className={`rounded-2xl px-4 py-3 ${
          isUser
            ? 'bg-primary text-white rounded-tr-none'
            : 'bg-zinc-900 text-zinc-100 border border-zinc-800 rounded-tl-none'
        }`}>
          {isUser ? (
            <p className="whitespace-pre-wrap text-[15px] leading-relaxed">{message.content}</p>
          ) : (
            <ReactMarkdown
              remarkPlugins={[remarkGfm]}
              className="prose prose-invert prose-sm max-w-none"
              components={{
                code({ node, inline, className, children, ...props }) {
                  const match = /language-(\w+)/.exec(className || '')
                  return !inline && match ? (
                    <SyntaxHighlighter
                      style={vscDarkPlus}
                      language={match[1]}
                      PreTag="div"
                      className="rounded-lg my-2"
                      {...props}
                    >
                      {String(children).replace(/\n$/, '')}
                    </SyntaxHighlighter>
                  ) : (
                    <code className="bg-zinc-800 px-1.5 py-0.5 rounded text-sm font-mono" {...props}>
                      {children}
                    </code>
                  )
                },
                a({ node, children, ...props }) {
                  return (
                    <a className="text-primary hover:underline" {...props}>
                      {children}
                    </a>
                  )
                },
              }}
            >
              {message.content}
            </ReactMarkdown>
          )}

          {/* Sources */}
          {message.sources && message.sources.length > 0 && (
            <div className="mt-3 pt-3 border-t border-zinc-700">
              <p className="text-xs font-bold text-zinc-400 mb-2">SOURCES</p>
              <div className="flex flex-wrap gap-2">
                {message.sources.map((source, i) => (
                  <span
                    key={i}
                    className="flex items-center gap-1.5 bg-zinc-800 px-2 py-1 rounded text-xs text-zinc-400 border border-zinc-700"
                  >
                    <span className="material-symbols-outlined !text-xs">description</span>
                    {source}
                  </span>
                ))}
              </div>
            </div>
          )}
        </div>
      </div>
    </div>
  )
}

// Helper function
function formatTimestamp(date: Date): string {
  const now = new Date()
  const diff = now.getTime() - date.getTime()
  const minutes = Math.floor(diff / 60000)
  
  if (minutes < 1) return 'just now'
  if (minutes < 60) return `${minutes}m ago`
  if (minutes < 1440) return `${Math.floor(minutes / 60)}h ago`
  return date.toLocaleDateString()
}

STEP 7: Improve ChatInput with File Upload

File: src/components/chat/ChatInput.tsx (REPLACE EXISTING)

import { useState, useRef, KeyboardEvent } from 'react'
import { Send, Paperclip, X } from 'lucide-react'

interface ChatInputProps {
  onSend: (content: string, files?: File[]) => void
  disabled?: boolean
}

export function ChatInput({ onSend, disabled }: ChatInputProps) {
  const [input, setInput] = useState('')
  const [files, setFiles] = useState<File[]>([])
  const fileInputRef = useRef<HTMLInputElement>(null)
  const textareaRef = useRef<HTMLTextAreaElement>(null)

  const handleSend = () => {
    if (!input.trim() && files.length === 0) return
    onSend(input, files)
    setInput('')
    setFiles([])
  }

  const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault()
      handleSend()
    }
  }

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      setFiles(prev => [...prev, ...Array.from(e.target.files!)])
    }
  }

  return (
    <div className="max-w-3xl mx-auto w-full space-y-2">
      {/* File Preview */}
      {files.length > 0 && (
        <div className="flex flex-wrap gap-2">
          {files.map((file, idx) => (
            <div
              key={idx}
              className="flex items-center gap-2 px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg"
            >
              <Paperclip className="w-4 h-4 text-zinc-400" />
              <span className="text-sm text-zinc-300">{file.name}</span>
              <button
                onClick={() => setFiles(files.filter((_, i) => i !== idx))}
                className="text-zinc-500 hover:text-zinc-300 transition-colors"
              >
                <X className="w-4 h-4" />
              </button>
            </div>
          ))}
        </div>
      )}

      {/* Input Container */}
      <div className="backdrop-blur-xl bg-zinc-900/50 border border-zinc-700 rounded-2xl p-3 shadow-2xl">
        {/* Textarea */}
        <div className="relative">
          <textarea
            ref={textareaRef}
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyDown={handleKeyDown}
            placeholder="Ask your brain anything..."
            disabled={disabled}
            className="w-full bg-transparent border-none focus:ring-0 text-zinc-100 placeholder-zinc-500 resize-none text-[15px] max-h-48 pr-10"
            rows={1}
            style={{
              minHeight: '52px',
              overflowY: input.split('\n').length > 3 ? 'auto' : 'hidden'
            }}
          />

          {/* File Upload Button */}
          <button
            onClick={() => fileInputRef.current?.click()}
            disabled={disabled}
            className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 disabled:opacity-50 transition-colors"
          >
            <Paperclip className="w-5 h-5" />
          </button>
          <input
            ref={fileInputRef}
            type="file"
            multiple
            onChange={handleFileChange}
            className="hidden"
          />
        </div>

        {/* Bottom Bar */}
        <div className="flex items-center justify-between mt-2 pt-2 border-t border-zinc-800">
          <span className="text-xs text-zinc-600">
            {input.length} characters  Shift+Enter for new line
          </span>
          
          <button
            onClick={handleSend}
            disabled={(!input.trim() && files.length === 0) || disabled}
            className="bg-primary hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed text-white p-2.5 rounded-xl transition-all shadow-lg shadow-primary/20"
          >
            <Send className="w-5 h-5" />
          </button>
        </div>
      </div>
    </div>
  )
}

STEP 8: Refactor ChatPage

File: src/pages/ChatPage.tsx (REPLACE EXISTING)

import { useState, useRef, useEffect } from 'react'
import { useChatStore } from '../stores/chatStore'
import { ChatMessage } from '../components/chat/ChatMessage'
import { ChatInput } from '../components/chat/ChatInput'
import { ModelSelector } from '../components/chat/ModelSelector'
import { RAGToggle } from '../components/chat/RAGToggle'
import { TypingIndicator } from '../components/chat/TypingIndicator'

export function ChatPage() {
  const { messages, isTyping, sendMessage } = useChatStore()
  const [selectedModel, setSelectedModel] = useState('llama3:8b')
  const [useRag, setUseRag] = useState(true)
  const bottomRef = useRef<HTMLDivElement>(null)

  // Auto-scroll to bottom on new messages
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [messages, isTyping])

  const handleSend = async (content: string, files?: File[]) => {
    await sendMessage({
      content,
      files,
      model: selectedModel,
      useRag
    })
  }

  return (
    <div className="flex flex-col h-full relative">
      {/* Top Bar */}
      <div className="border-b border-zinc-800 px-6 py-4 flex items-center justify-between flex-shrink-0">
        <div className="flex items-center gap-3">
          <ModelSelector value={selectedModel} onChange={setSelectedModel} />
          <RAGToggle enabled={useRag} onChange={setUseRag} />
        </div>
      </div>

      {/* Messages Area - Scrollable */}
      <div className="flex-1 overflow-y-auto custom-scrollbar">
        <div className="py-8">
          {messages.map((msg) => (
            <ChatMessage key={msg.id} message={msg} />
          ))}
          
          {isTyping && <TypingIndicator />}
          
          <div ref={bottomRef} />
        </div>
      </div>

      {/* Input Area - FIXED BOTTOM */}
      <div className="border-t border-zinc-800 p-4 bg-gradient-to-t from-zinc-950 to-transparent flex-shrink-0">
        <ChatInput onSend={handleSend} disabled={isTyping} />
      </div>
    </div>
  )
}

STEP 9: Add Empty States to Sidebar

File: src/components/layout/Sidebar.tsx (UPDATE EXISTING)

Find the "Recents" section (around line 135) and replace with:

{/* Recents */}
<div className="flex flex-col gap-1 px-2 mb-6">
  <p className="px-3 text-[11px] font-bold text-zinc-500 uppercase tracking-widest mb-2">
    Recents
  </p>
  {recents.length === 0 ? (
    <div className="px-3 py-8 text-center">
      <span className="material-symbols-outlined text-zinc-700 !text-4xl mb-2 block">description</span>
      <p className="text-xs text-zinc-600 mb-1">No recent documents</p>
      <p className="text-xs text-zinc-700">Upload files to get started</p>
    </div>
  ) : (
    recents.map((item) => (
      <div
        key={item.name}
        className="flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all hover:bg-zinc-900 text-zinc-500 hover:text-white"
      >
        <span className="material-symbols-outlined !text-[20px]">{item.icon}</span>
        <p className="text-sm font-medium truncate flex-1">{item.name}</p>
      </div>
    ))
  )}
</div>

Find the "History" section (around line 155) and replace with:

{/* History */}
<div className="flex flex-col gap-1 px-2 flex-1 overflow-y-auto">
  <p className="px-3 text-[11px] font-bold text-zinc-500 uppercase tracking-widest mb-2">
    History
  </p>
  {chats.length === 0 ? (
    <div className="px-3 py-8 text-center">
      <span className="material-symbols-outlined text-zinc-700 !text-4xl mb-2 block">chat_bubble</span>
      <p className="text-xs text-zinc-600 mb-1">No chat history</p>
      <p className="text-xs text-zinc-700">Start chatting to see history</p>
    </div>
  ) : (
    chats.map((chat) => (
      <div
        key={chat.id}
        className="flex items-center gap-3 px-3 py-1.5 text-zinc-500 hover:text-white cursor-pointer text-sm transition-colors group"
      >
        <span className="material-symbols-outlined !text-[18px] group-hover:text-primary transition-colors">
          {chat.icon}
        </span>
        <span className="truncate">{chat.name}</span>
      </div>
    ))
  )}
</div>

STEP 10: Create UI Helper Components

File: src/components/ui/EmptyState.tsx

import { LucideIcon } from 'lucide-react'

interface EmptyStateProps {
  icon: LucideIcon
  title: string
  description?: string
  action?: React.ReactNode
}

export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
  return (
    <div className="flex flex-col items-center justify-center h-full py-12 text-center px-4">
      <Icon className="w-16 h-16 text-zinc-700 mb-4" />
      <h3 className="text-lg font-semibold text-zinc-300 mb-2">{title}</h3>
      {description && <p className="text-sm text-zinc-500 mb-6 max-w-md">{description}</p>}
      {action}
    </div>
  )
}

File: src/components/ui/Skeleton.tsx

import { cn } from '../../lib/utils'

interface SkeletonProps {
  className?: string
}

export function Skeleton({ className }: SkeletonProps) {
  return (
    <div className={cn("animate-pulse bg-zinc-800 rounded", className)} />
  )
}

File: src/components/ui/ErrorBoundary.tsx

import { Component, ErrorInfo, ReactNode } from 'react'
import { AlertTriangle } from 'lucide-react'

interface Props {
  children: ReactNode
}

interface State {
  hasError: boolean
  error?: Error
}

export class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false }

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error('ErrorBoundary caught:', error, errorInfo)
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="min-h-screen flex items-center justify-center bg-zinc-950 p-4">
          <div className="max-w-md w-full text-center">
            <AlertTriangle className="w-16 h-16 text-red-500 mx-auto mb-4" />
            <h1 className="text-2xl font-bold text-zinc-100 mb-2">Something went wrong</h1>
            <p className="text-zinc-400 mb-6">
              {this.state.error?.message || 'An unexpected error occurred'}
            </p>
            <button
              onClick={() => window.location.reload()}
              className="px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
            >
              Reload Page
            </button>
          </div>
        </div>
      )
    }

    return this.props.children
  }
}

File: src/components/ui/Toast.tsx

import { Toaster } from 'sonner'

export function ToastProvider() {
  return (
    <Toaster
      position="bottom-right"
      theme="dark"
      toastOptions={{
        className: 'bg-zinc-900 border border-zinc-700 text-zinc-100',
      }}
    />
  )
}

STEP 11: Add Toast Provider to App

File: src/main.tsx (UPDATE)

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import { ToastProvider } from './components/ui/Toast'
import './index.css'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
    <ToastProvider />
  </React.StrictMode>,
)

STEP 12: Add Error Boundary to App

File: src/App.tsx (UPDATE)

import { BrowserRouter, Routes, Route } from "react-router-dom"
import { MainLayout } from "./components/layout/MainLayout"
import { ChatPage } from "./pages/ChatPage"
import { LibraryPage } from "./pages/LibraryPage"
import { APIPage } from "./pages/APIPage"
import { NeuralMapPage } from "./pages/NeuralMapPage"
import { ErrorBoundary } from "./components/ui/ErrorBoundary"

function App() {
  return (
    <ErrorBoundary>
      <BrowserRouter>
        <Routes>
          <Route path="/" element={<MainLayout />}>
            <Route index element={<ChatPage />} />
            <Route path="library" element={<LibraryPage />} />
            <Route path="api-management" element={<APIPage />} />
            <Route path="neural-map" element={<NeuralMapPage />} />
          </Route>
        </Routes>
      </BrowserRouter>
    </ErrorBoundary>
  )
}

export default App

STEP 13: Create Keyboard Shortcuts Hook

File: src/hooks/useKeyboardShortcuts.ts

import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useChatStore } from '../stores/chatStore'

export function useKeyboardShortcuts() {
  const navigate = useNavigate()
  const { clearMessages } = useChatStore()

  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      const isMod = e.metaKey || e.ctrlKey

      // Cmd/Ctrl + N: New Chat
      if (isMod && e.key === 'n') {
        e.preventDefault()
        clearMessages()
        navigate('/')
      }

      // Cmd/Ctrl + L: Library
      if (isMod && e.key === 'l') {
        e.preventDefault()
        navigate('/library')
      }

      // Cmd/Ctrl + M: Neural Map
      if (isMod && e.key === 'm') {
        e.preventDefault()
        navigate('/neural-map')
      }
    }

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

Then add to App.tsx:

import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'

function App() {
  useKeyboardShortcuts() // Add this line
  
  return (
    // ... rest
  )
}

STEP 14: Create Media Query Hook

File: src/hooks/useMediaQuery.ts

import { useState, useEffect } from 'react'

export function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(false)

  useEffect(() => {
    const media = window.matchMedia(query)
    if (media.matches !== matches) {
      setMatches(media.matches)
    }

    const listener = () => setMatches(media.matches)
    media.addEventListener('change', listener)
    return () => media.removeEventListener('change', listener)
  }, [matches, query])

  return matches
}

STEP 15: Improve Mobile Menu in Header

File: src/components/layout/Header.tsx (UPDATE if exists, or skip if basic)

Add hamburger button for mobile:

import { Menu } from 'lucide-react'
import { useUIStore } from '../../stores/uiStore'

export function Header() {
  const { toggleSidebar } = useUIStore()

  return (
    <header className="border-b border-zinc-800 px-4 py-3 flex items-center gap-4 flex-shrink-0">
      {/* Mobile hamburger */}
      <button
        onClick={toggleSidebar}
        className="md:hidden text-zinc-400 hover:text-zinc-100 transition-colors"
      >
        <Menu className="w-6 h-6" />
      </button>

      {/* Search */}
      <div className="flex-1 max-w-2xl mx-auto">
        <input
          type="text"
          placeholder="Search notes..."
          className="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-4 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all"
        />
      </div>
    </header>
  )
}

Testing Checklist

After implementation, verify:

Chat Interface

  • Typing indicator shows 3 animated dots when AI is responding
  • Timestamps appear on all messages in relative format ("2m ago")
  • Markdown renders correctly (bold, italic, lists, code blocks)
  • Code blocks have syntax highlighting
  • User messages: blue background, right-aligned
  • AI messages: zinc background, left-aligned, show sources
  • Input stays at bottom, always visible
  • File upload shows preview chips with remove button
  • Auto-scroll works smoothly on new messages

Model Selection

  • Dropdown opens/closes correctly
  • Selected model is highlighted
  • Clicking outside closes dropdown
  • Models load from API if available

RAG Toggle

  • Visual difference clear between ON/OFF states
  • Sources show when RAG is ON
  • No sources when RAG is OFF

Sidebar

  • Empty states show professional designs (not blank)
  • Hamburger menu works on mobile (<768px)
  • Sidebar overlays with backdrop on mobile
  • Sidebar always visible on desktop (≥768px)
  • Recents populate when documents exist
  • History populates when chats exist

Responsive

  • Test on 320px width (mobile)
  • Test on 768px width (tablet)
  • Test on 1024px+ width (desktop)
  • No horizontal scroll at any breakpoint
  • All interactive elements are tappable on mobile

Functionality

  • Keyboard shortcuts work (Cmd+N, Cmd+L, Cmd+M)
  • Error boundary catches errors
  • Toast notifications appear
  • No console errors
  • TypeScript compiles without errors

🎯 Success Criteria

The rebuild is complete when:

  1. Chat feels professional - Like ChatGPT/Claude, not a prototype
  2. Typing indicator is animated - 3 bouncing dots, not just spinning icon
  3. Timestamps are visible - Every message shows time
  4. Markdown works perfectly - Code highlighting, proper formatting
  5. Empty states look good - No blank sections anywhere
  6. Mobile is fully functional - Hamburger menu, responsive layout
  7. No errors - Console clean, TypeScript compiles
  8. Smooth UX - Fast, responsive, professional

🚨 Critical Notes

  • DO NOT TOUCH anything in /server directory
  • PRESERVE existing API endpoints (they work fine)
  • REUSE existing Button, Card, Input components where possible
  • USE Material Symbols icons (already loaded) + Lucide for new icons
  • DARK MODE ONLY - No light mode needed
  • KEEP existing color palette from tailwind.config.js

📝 Jules Instructions

  1. Start by installing dependencies
  2. Create folder structure (hooks, stores, types)
  3. Build components in this order:
    • Types → Stores → TypingIndicator → ModelSelector → RAGToggle
    • Update ChatMessage → Update ChatInput → Refactor ChatPage
    • Add empty states to Sidebar
    • Create helper components (EmptyState, Skeleton, ErrorBoundary)
  4. Test everything thoroughly
  5. Confirm all checklist items pass

Use Stitch for code generation. Work incrementally, test as you go. Focus on quality over speed.

Good luck! 🚀