1179 lines
34 KiB
Markdown
1179 lines
34 KiB
Markdown
|
|
# 🧠 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:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
{/* 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:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
{/* 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`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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`:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||
|
|
|
||
|
|
function App() {
|
||
|
|
useKeyboardShortcuts() // Add this line
|
||
|
|
|
||
|
|
return (
|
||
|
|
// ... rest
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### STEP 14: Create Media Query Hook
|
||
|
|
|
||
|
|
**File:** `src/hooks/useMediaQuery.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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! 🚀
|