Skip to content

Browser Storage

Your Summary Memory chat is smart, but there’s one problem - refresh the page and everything disappears! 😱

That’s where localStorage comes in. It’s like giving your chat app a memory that survives browser restarts. You’ll add a conversation sidebar, auto-save every message, and create a professional chat experience that feels like the apps you use every day.

Building on: This lesson enhances your existing Summary Memory implementation. Make sure you have Summary Memory working before starting here.


You’ll transform your Summary Memory chat to include:

  • βœ… Auto-save conversations to browser storage after every message
  • βœ… Auto-restore chats when page loads, including summaries and settings
  • βœ… Conversation sidebar with saved conversation list
  • βœ… Simple conversation management (new, load, delete)
  • βœ… Fixed input position - stays at bottom, doesn’t scroll away

Why localStorage + Summary Memory works great:

// Perfect combination for learning and demos
const benefits = {
setup: '15 minutes, zero backend changes',
features: 'Smart summarization + persistent storage',
privacy: 'Data never leaves user device',
cost: 'Completely free'
}
// The trade-offs you're making
const limitations = {
devices: 'Single device/browser only',
storage: '5MB browser limit',
sharing: 'No multi-user support'
}

This combo gives you a professional chat experience perfect for demos, personal projects, and learning how persistence works!


First, let’s add the localStorage functions to your Summary Memory component. These handle all the saving and loading logic.

Add these functions at the top of your src/App.jsx file, right after your imports:

import { useState, useRef } from 'react'
import { Send, Bot, User, Trash2, Plus, MessageSquare } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
// πŸ†• LOCAL STORAGE: Simple storage functions
const STORAGE_KEY = 'ai-conversations'
const saveConversation = (conversationId, messages, summary, conversationType) => {
const conversations = getAllConversations()
const conversationData = {
id: conversationId,
messages: messages.filter(msg => !msg.isStreaming),
summary,
conversationType,
title: generateTitle(messages),
timestamp: new Date().toISOString(),
messageCount: messages.length
}
conversations[conversationId] = conversationData
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations))
console.log('πŸ’Ύ Conversation saved to localStorage')
} catch (error) {
console.error('Failed to save:', error)
}
}
const getAllConversations = () => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
return stored ? JSON.parse(stored) : {}
} catch (error) {
console.error('Failed to load conversations:', error)
return {}
}
}
const loadConversation = (conversationId) => {
const conversations = getAllConversations()
return conversations[conversationId] || null
}
const deleteConversation = (conversationId) => {
const conversations = getAllConversations()
delete conversations[conversationId]
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations))
console.log('πŸ—‘οΈ Conversation deleted')
} catch (error) {
console.error('Failed to delete conversation:', error)
}
}
const generateTitle = (messages) => {
if (!messages || messages.length === 0) return 'New Conversation'
const firstUserMessage = messages.find(msg => msg.isUser)
if (firstUserMessage) {
return firstUserMessage.text.length > 30
? firstUserMessage.text.substring(0, 30) + '...'
: firstUserMessage.text
}
return 'New Conversation'
}
const generateId = () => {
return Date.now().toString() + Math.random().toString(36).substr(2, 9)
}

What these functions do:

  • saveConversation - Stores conversation data to localStorage, including all messages and summary
  • getAllConversations - Gets all saved conversations for the sidebar list
  • loadConversation - Retrieves a specific conversation by ID
  • deleteConversation - Removes a conversation from storage
  • generateTitle - Creates a readable title from the first user message
  • generateId - Makes unique IDs using timestamp + random string

Now let’s add new state variables to track conversations and the sidebar. Add these lines right after your existing Summary Memory state:

function App() {
// Your existing state (keep all of this)
const [messages, setMessages] = useState([])
const [input, setInput] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const abortControllerRef = useRef(null)
// Summary Memory state (keep all of this)
const [summary, setSummary] = useState(null)
const [recentWindowSize, setRecentWindowSize] = useState(15)
const [summaryThreshold, setSummaryThreshold] = useState(25)
const [isCreatingSummary, setIsCreatingSummary] = useState(false)
const [conversationType, setConversationType] = useState('general')
// πŸ†• LOCAL STORAGE: Add conversation management state
const [currentConversationId, setCurrentConversationId] = useState(null)
const [conversations, setConversations] = useState({})
const [showSidebar, setShowSidebar] = useState(true)

What the new state does:

  • currentConversationId - Tracks which conversation is currently active
  • conversations - Stores all conversation data for the sidebar display
  • showSidebar - Controls whether the sidebar is visible (you can collapse it)

Add these useEffect hooks right after your state declarations. They handle loading conversations when the app starts and auto-saving when things change:

// Keep ALL your existing Summary Memory functions here
// (buildConversationHistory, detectConversationType, createSummary, etc.)
// πŸ†• LOCAL STORAGE: Load conversations on startup
useEffect(() => {
const allConversations = getAllConversations()
setConversations(allConversations)
// Load the most recent conversation if it exists
const conversationIds = Object.keys(allConversations)
if (conversationIds.length > 0) {
const mostRecent = conversationIds
.map(id => allConversations[id])
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))[0]
if (mostRecent) {
loadConversationById(mostRecent.id)
}
}
}, [])
// πŸ†• LOCAL STORAGE: Auto-save when conversation changes
useEffect(() => {
if (currentConversationId && messages.length > 0) {
saveConversation(currentConversationId, messages, summary, conversationType)
// Refresh conversation list for sidebar
setConversations(getAllConversations())
}
}, [messages, summary, conversationType, currentConversationId])

What the auto-save logic does:

  • First useEffect - Runs once when app loads, gets all saved conversations and loads the most recent one
  • Second useEffect - Runs whenever messages, summary, or conversation type changes, automatically saves everything
  • No manual saving needed - Everything happens in the background!

πŸ› οΈ Step 4: Add Conversation Management Functions

Section titled β€œπŸ› οΈ Step 4: Add Conversation Management Functions”

Add these functions right after your existing Summary Memory helper functions:

// πŸ†• LOCAL STORAGE: Conversation management functions
const startNewConversation = () => {
setCurrentConversationId(generateId())
setMessages([])
setSummary(null)
setConversationType('general')
}
const loadConversationById = (conversationId) => {
const conversation = loadConversation(conversationId)
if (conversation) {
setCurrentConversationId(conversationId)
setMessages(conversation.messages || [])
setSummary(conversation.summary || null)
setConversationType(conversation.conversationType || 'general')
console.log(`βœ… Loaded conversation: ${conversation.title}`)
}
}
const deleteConversationById = (conversationId) => {
deleteConversation(conversationId)
setConversations(getAllConversations())
if (currentConversationId === conversationId) {
startNewConversation()
}
}

What these functions do:

  • startNewConversation - Creates a fresh conversation and clears all state
  • loadConversationById - Loads a saved conversation and restores its complete state
  • deleteConversationById - Removes a conversation and starts a new one if you were viewing the deleted one

Your original layout had a problem - the input field scrolled away with long conversations! Let’s fix that so it stays put like professional chat apps.

The fix: Use a fixed-height layout where only the messages area scrolls.

Replace your entire return statement with this improved layout:

return (
<div className="h-screen bg-gray-100 flex">
{/* πŸ†• LOCAL STORAGE: Conversation Sidebar */}
{showSidebar && (
<div className="w-80 bg-white border-r border-gray-200 flex flex-col">
{/* Sidebar Header */}
<div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-800">Conversations</h2>
<button
onClick={() => setShowSidebar(false)}
className="text-gray-400 hover:text-gray-600"
>
βœ•
</button>
</div>
<button
onClick={startNewConversation}
className="w-full bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600 flex items-center justify-center space-x-2"
>
<Plus className="w-4 h-4" />
<span>New Chat</span>
</button>
</div>
{/* Conversation List */}
<div className="flex-1 overflow-y-auto">
{Object.values(conversations)
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
.map(conversation => (
<div
key={conversation.id}
className={`p-3 border-b border-gray-100 cursor-pointer hover:bg-gray-50 ${
currentConversationId === conversation.id ? 'bg-blue-50 border-l-4 border-l-blue-500' : ''
}`}
onClick={() => loadConversationById(conversation.id)}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-gray-800 truncate">
{conversation.title}
</h3>
<div className="flex items-center space-x-2 mt-1">
<span className="text-xs text-gray-500">
{conversation.messageCount} messages
</span>
{conversation.summary && (
<span className="text-xs bg-green-100 text-green-600 px-2 py-0.5 rounded">
πŸ“‹ Summary
</span>
)}
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-0.5 rounded">
{conversation.conversationType}
</span>
</div>
<p className="text-xs text-gray-400 mt-1">
{new Date(conversation.timestamp).toLocaleDateString()}
</p>
</div>
<button
onClick={(e) => {
e.stopPropagation()
deleteConversationById(conversation.id)
}}
className="text-gray-400 hover:text-red-500 ml-2"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))
}
{Object.keys(conversations).length === 0 && (
<div className="p-6 text-center text-gray-500">
<MessageSquare className="w-8 h-8 mx-auto mb-2 text-gray-300" />
<p className="text-sm">No conversations yet</p>
<p className="text-xs">Start a new chat to begin</p>
</div>
)}
</div>
</div>
)}
{/* Main Chat Area - Fixed Layout */}
<div className="flex-1 flex flex-col min-h-0">
{/* Chat Header - Fixed at top */}
<div className="bg-blue-500 text-white p-4 flex-shrink-0">
<div className="flex justify-between items-start">
<div className="flex items-center space-x-3">
{!showSidebar && (
<button
onClick={() => setShowSidebar(true)}
className="text-blue-100 hover:text-white"
>
<MessageSquare className="w-5 h-5" />
</button>
)}
<div>
<h1 className="text-xl font-bold">Summary Memory + Local Storage</h1>
<p className="text-blue-100 text-sm">
Smart conversation memory with persistent storage
</p>
</div>
</div>
<div className="text-right space-y-2">
<div>
<label className="block text-xs text-blue-100">Recent: {recentWindowSize}</label>
<input
type="range" min="5" max="30" value={recentWindowSize}
onChange={(e) => setRecentWindowSize(parseInt(e.target.value))}
className="w-20" disabled={isStreaming}
/>
</div>
<div>
<label className="block text-xs text-blue-100">Summary at: {summaryThreshold}</label>
<input
type="range" min="15" max="50" value={summaryThreshold}
onChange={(e) => setSummaryThreshold(parseInt(e.target.value))}
className="w-20" disabled={isStreaming}
/>
</div>
</div>
</div>
</div>
{/* Memory Status Dashboard - Fixed below header */}
<div className="bg-gray-50 px-4 py-3 border-b flex-shrink-0">
{(() => {
const { totalMessages, recentMessages, summarizedMessages } = getMemoryStats();
return (
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<div className="flex space-x-4 text-gray-600">
<span>πŸ“Š Total: {totalMessages}</span>
<span>πŸ”₯ Recent: {recentMessages}</span>
{summarizedMessages > 0 && (
<span>πŸ“ Summarized: {summarizedMessages}</span>
)}
<span className="text-blue-600">🧠 {conversationType}</span>
</div>
<div className="flex items-center space-x-2 text-xs">
{summary && (
<span className="text-green-600">βœ… Summary Active</span>
)}
{isCreatingSummary && (
<span className="text-blue-600">πŸ”„ Creating Summary...</span>
)}
<span className="text-purple-600">
πŸ’Ύ {Object.keys(conversations).length} saved
</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{
width: `${Math.min(100, (totalMessages / 50) * 100)}%`
}}
/>
</div>
</div>
);
})()}
</div>
{/* Active Summary Display - Fixed when present */}
{summary && (
<div className="bg-blue-50 border-l-4 border-blue-400 p-3 mx-4 mt-2 rounded flex-shrink-0">
<div className="flex items-start">
<span className="text-blue-600 mr-2">πŸ“‹</span>
<div className="flex-1">
<p className="text-xs font-medium text-blue-800 mb-1">
Active Summary ({conversationType}) β€’ Auto-saved
</p>
<p className="text-xs text-blue-700 leading-relaxed">
{summary}
</p>
</div>
</div>
</div>
)}
{/* Messages - ONLY this area scrolls */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 min-h-0">
{messages.length === 0 && (
<div className="text-center text-gray-500 mt-20">
<Bot className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p>Send a message to see Summary Memory + Local Storage in action!</p>
{Object.keys(conversations).length > 0 && (
<p className="text-sm mt-2 text-blue-600">
πŸ’Ύ Select a conversation from the sidebar or start a new one
</p>
)}
</div>
)}
{messages.map((message) => (
<div
key={message.id}
className={`flex items-start space-x-3 ${
message.isUser ? 'justify-end' : 'justify-start'
}`}
>
{!message.isUser && (
<div className="bg-blue-500 p-2 rounded-full">
<Bot className="w-4 h-4 text-white" />
</div>
)}
<div
className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${
message.isUser
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-800'
}`}
>
<MessageContent message={message} />
</div>
{message.isUser && (
<div className="bg-gray-500 p-2 rounded-full">
<User className="w-4 h-4 text-white" />
</div>
)}
</div>
))}
{/* Breathing room at bottom */}
<div className="h-4"></div>
</div>
{/* Input Area - Fixed at bottom */}
<div className="border-t bg-white p-4 flex-shrink-0">
<div className="flex space-x-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type your message..."
className="flex-1 border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isStreaming}
/>
{isStreaming ? (
<button
onClick={stopStreaming}
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-lg transition-colors"
>
Stop
</button>
) : (
<button
onClick={sendMessage}
disabled={!input.trim()}
className="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 text-white p-2 rounded-lg transition-colors"
>
<Send className="w-5 h-5" />
</button>
)}
</div>
</div>
</div>
</div>
)

Key layout improvements:

  • h-screen - Uses full viewport height instead of growing content
  • flex-shrink-0 - Keeps header, dashboard, and input fixed in place
  • flex-1 overflow-y-auto - Makes only the messages area scrollable
  • min-h-0 - Allows proper flex behavior for scrolling

Find your sendMessage function and make one small but important change. After you create the user message, add logic to create a new conversation if none exists:

const sendMessage = async () => {
if (!input.trim() || isStreaming) return
// πŸ†• LOCAL STORAGE: Create new conversation if none exists
if (!currentConversationId) {
setCurrentConversationId(generateId())
}
const userMessage = { text: input, isUser: true, id: Date.now() }
setMessages(prev => [...prev, userMessage])
// Rest of your sendMessage function stays exactly the same
// (all the Summary Memory logic, streaming, error handling, etc.)

Why this change matters: The first time someone sends a message, there’s no conversation ID yet. This creates one automatically so the auto-save logic works from the very first message.


Start your servers:

Backend:

Terminal window
cd openai-backend
npm run dev

Frontend:

Terminal window
cd openai-frontend
npm run dev

Test the complete functionality with this flow:

1. You: "Hi! My name is Alex and I'm a software engineer"
AI: "Nice to meet you, Alex! Great to know you're in software engineering."
2. Continue until you have 10+ messages
β€’ Notice conversation appears in sidebar automatically
β€’ Watch memory stats update as you chat
β€’ Observe that input stays at bottom even in long conversations
3. Click "New Chat" button
β€’ Start fresh conversation about cooking
β€’ Watch both conversations appear in sidebar
4. Refresh the page
β€’ All conversations should load in sidebar
β€’ Most recent conversation should auto-load
β€’ All summaries and settings should restore perfectly
5. Click different conversations in sidebar
β€’ Each loads with complete message history
β€’ Summaries and conversation types restore correctly
β€’ Layout stays professional with fixed input
6. Create a conversation with 50+ messages
β€’ Scroll through the conversation
β€’ Input field should always stay visible at bottom
β€’ Only the message area should scroll
β€’ Sidebar should remain accessible

Success looks like:

  • βœ… Conversations auto-save after every message
  • βœ… Page refresh preserves all data perfectly
  • βœ… Sidebar shows conversation list with titles and metadata
  • βœ… Input field never scrolls away, stays fixed at bottom
  • βœ… All Summary Memory features continue working
  • βœ… Layout feels like a professional chat application

❌ β€œConversations don’t save or sidebar is empty”

  • βœ… Check browser console for localStorage errors
  • βœ… Verify currentConversationId gets set when sending first message
  • βœ… Make sure the useEffect hooks are inside your component function

❌ β€œPage refresh doesn’t restore conversation”

  • βœ… Check that getAllConversations() returns data in browser console
  • βœ… Verify the first useEffect runs on page load
  • βœ… Make sure you’re not clearing localStorage elsewhere

❌ β€œInput field scrolls away or layout looks broken”

  • βœ… Verify you’re using h-screen instead of min-h-screen on container
  • βœ… Check that messages area has flex-1 overflow-y-auto min-h-0
  • βœ… Ensure input area has flex-shrink-0 class

❌ β€œSidebar doesn’t show conversation details”

  • βœ… Check that conversations state is updating in the second useEffect
  • βœ… Verify generateTitle() function creates proper titles
  • βœ… Make sure conversation metadata (messageCount, timestamp) saves correctly

// What localStorage contains:
const conversations = {
"conv_123": {
id: "conv_123",
title: "Hi! My name is Alex and I'm a...",
messages: [...], // Complete message history
summary: "User Alex is a software engineer...", // Summary Memory data
conversationType: "technical",
messageCount: 25,
timestamp: "2024-01-15T10:30:00Z"
}
}
Container (h-screen)
β”œβ”€β”€ Sidebar (w-80, flex flex-col)
β”‚ β”œβ”€β”€ Header (flex-shrink-0)
β”‚ └── Conversation List (flex-1 overflow-y-auto)
└── Chat Area (flex-1 flex flex-col min-h-0)
β”œβ”€β”€ Header (flex-shrink-0)
β”œβ”€β”€ Memory Dashboard (flex-shrink-0)
β”œβ”€β”€ Summary Display (flex-shrink-0)
β”œβ”€β”€ Messages (flex-1 overflow-y-auto min-h-0) ← ONLY SCROLLS
└── Input (flex-shrink-0) ← ALWAYS VISIBLE
  1. User sends message β†’ Messages state updates
  2. useEffect detects change β†’ Calls saveConversation()
  3. localStorage updated β†’ Conversation data persisted
  4. Sidebar refreshes β†’ Shows updated conversation list

Here’s your complete src/App.jsx with all Local Storage + Summary Memory functionality integrated:

import { useState, useRef, useEffect } from 'react'
import { Send, Bot, User, Trash2, Plus, MessageSquare } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
// πŸ†• LOCAL STORAGE: Simple storage functions
const STORAGE_KEY = 'ai-conversations'
const saveConversation = (conversationId, messages, summary, conversationType) => {
const conversations = getAllConversations()
const conversationData = {
id: conversationId,
messages: messages.filter(msg => !msg.isStreaming),
summary,
conversationType,
title: generateTitle(messages),
timestamp: new Date().toISOString(),
messageCount: messages.length
}
conversations[conversationId] = conversationData
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations))
console.log('πŸ’Ύ Conversation saved to localStorage')
} catch (error) {
console.error('Failed to save:', error)
}
}
const getAllConversations = () => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
return stored ? JSON.parse(stored) : {}
} catch (error) {
console.error('Failed to load conversations:', error)
return {}
}
}
const loadConversation = (conversationId) => {
const conversations = getAllConversations()
return conversations[conversationId] || null
}
const deleteConversation = (conversationId) => {
const conversations = getAllConversations()
delete conversations[conversationId]
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations))
console.log('πŸ—‘οΈ Conversation deleted')
} catch (error) {
console.error('Failed to delete conversation:', error)
}
}
const generateTitle = (messages) => {
if (!messages || messages.length === 0) return 'New Conversation'
const firstUserMessage = messages.find(msg => msg.isUser)
if (firstUserMessage) {
return firstUserMessage.text.length > 30
? firstUserMessage.text.substring(0, 30) + '...'
: firstUserMessage.text
}
return 'New Conversation'
}
const generateId = () => {
return Date.now().toString() + Math.random().toString(36).substr(2, 9)
}
// Component: Handles message content with markdown formatting
function MessageContent({ message }) {
if (message.isUser) {
return (
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{message.text}
{message.isStreaming && (
<span className="inline-block w-2 h-4 bg-blue-500 ml-1 animate-pulse" />
)}
</p>
)
}
return (
<div className="text-sm leading-relaxed">
<ReactMarkdown
components={{
h1: ({children}) => <h1 className="text-lg font-bold mb-2 text-slate-800">{children}</h1>,
h2: ({children}) => <h2 className="text-base font-bold mb-2 text-slate-800">{children}</h2>,
h3: ({children}) => <h3 className="text-sm font-bold mb-1 text-slate-800">{children}</h3>,
p: ({children}) => <p className="mb-2 last:mb-0 text-slate-700">{children}</p>,
ul: ({children}) => <ul className="list-disc list-inside mb-2 space-y-1">{children}</ul>,
ol: ({children}) => <ol className="list-decimal list-inside mb-2 space-y-1">{children}</ol>,
li: ({children}) => <li className="text-slate-700">{children}</li>,
code: ({inline, children}) => {
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text)
}
if (inline) {
return (
<code className="bg-slate-100 text-red-600 px-1.5 py-0.5 rounded text-xs font-mono border">
{children}
</code>
)
}
return (
<div className="relative group mb-2">
<code className="block bg-gray-900 text-green-400 p-4 rounded-lg text-xs font-mono overflow-x-auto whitespace-pre border-l-4 border-blue-400 shadow-sm">
{children}
</code>
<button
onClick={() => copyToClipboard(children)}
className="absolute top-2 right-2 bg-slate-600 hover:bg-slate-500 text-white px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity"
>
Copy
</button>
</div>
)
},
pre: ({children}) => <div className="mb-2">{children}</div>,
strong: ({children}) => <strong className="font-semibold text-slate-800">{children}</strong>,
em: ({children}) => <em className="italic text-slate-700">{children}</em>,
blockquote: ({children}) => (
<blockquote className="border-l-4 border-blue-200 pl-4 italic text-slate-600 mb-2">
{children}
</blockquote>
),
a: ({href, children}) => (
<a href={href} className="text-blue-600 hover:text-blue-800 underline" target="_blank" rel="noopener noreferrer">
{children}
</a>
),
}}
>
{message.text}
</ReactMarkdown>
{message.isStreaming && (
<span className="inline-block w-2 h-4 bg-blue-500 ml-1 animate-pulse" />
)}
</div>
)
}
function App() {
// State management
const [messages, setMessages] = useState([])
const [input, setInput] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const abortControllerRef = useRef(null)
// Summary Memory state
const [summary, setSummary] = useState(null)
const [recentWindowSize, setRecentWindowSize] = useState(15)
const [summaryThreshold, setSummaryThreshold] = useState(25)
const [isCreatingSummary, setIsCreatingSummary] = useState(false)
const [conversationType, setConversationType] = useState('general')
// πŸ†• LOCAL STORAGE: Conversation management state
const [currentConversationId, setCurrentConversationId] = useState(null)
const [conversations, setConversations] = useState({})
const [showSidebar, setShowSidebar] = useState(true)
// MEMORY: Function to build conversation history
const buildConversationHistory = (messages) => {
return messages
.filter(msg => !msg.isStreaming)
.map(msg => ({
role: msg.isUser ? "user" : "assistant",
content: msg.text
}));
};
// SUMMARY MEMORY: Detect conversation type automatically
const detectConversationType = (messages) => {
const recentText = messages.slice(-10).map(m => m.text).join(' ').toLowerCase();
if (recentText.includes('function') || recentText.includes('code') || recentText.includes('api')) {
return 'technical';
} else if (recentText.includes('create') || recentText.includes('idea') || recentText.includes('design')) {
return 'creative';
} else if (recentText.includes('problem') || recentText.includes('error') || recentText.includes('help')) {
return 'support';
}
return 'general';
};
// SUMMARY MEMORY: Create summary with intelligent timing
const createSummary = async (messagesToSummarize) => {
if (isCreatingSummary) return;
try {
setIsCreatingSummary(true);
const detectedType = detectConversationType(messages);
const response = await fetch('http://localhost:8000/api/summarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: messagesToSummarize,
conversationType: detectedType
}),
});
const data = await response.json();
if (data.success) {
setSummary(data.summary);
setConversationType(data.conversationType);
console.log(`πŸ“‹ Summary created: ${data.messagesCount} messages summarized as ${data.conversationType}`);
}
} catch (error) {
console.error("Failed to create summary:", error);
} finally {
setIsCreatingSummary(false);
}
};
// SUMMARY MEMORY: Smart summary triggers
const shouldCreateSummary = (conversationHistory) => {
return conversationHistory.length >= summaryThreshold && !summary;
};
const shouldUpdateSummary = (conversationHistory) => {
return conversationHistory.length >= summaryThreshold * 2 && summary;
};
const isGoodTimeToSummarize = (conversationHistory) => {
const recentMessages = conversationHistory.slice(-3);
const hasCodeDiscussion = recentMessages.some(msg =>
msg.content.includes('```') || msg.content.includes('function'));
const hasFollowUp = recentMessages.some(msg =>
msg.content.toLowerCase().includes('can you explain') ||
msg.content.toLowerCase().includes('tell me more') ||
msg.content.toLowerCase().includes('what about'));
return !hasCodeDiscussion && !hasFollowUp;
};
// SUMMARY MEMORY: Calculate memory statistics
const getMemoryStats = () => {
const totalMessages = messages.filter(msg => !msg.isStreaming).length
const recentMessages = Math.min(totalMessages, recentWindowSize)
const summarizedMessages = Math.max(0, totalMessages - recentWindowSize)
return { totalMessages, recentMessages, summarizedMessages }
};
// πŸ†• LOCAL STORAGE: Load conversations on startup
useEffect(() => {
const allConversations = getAllConversations()
setConversations(allConversations)
// Load the most recent conversation if it exists
const conversationIds = Object.keys(allConversations)
if (conversationIds.length > 0) {
const mostRecent = conversationIds
.map(id => allConversations[id])
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))[0]
if (mostRecent) {
loadConversationById(mostRecent.id)
}
}
}, [])
// πŸ†• LOCAL STORAGE: Auto-save when conversation changes
useEffect(() => {
if (currentConversationId && messages.length > 0) {
saveConversation(currentConversationId, messages, summary, conversationType)
// Refresh conversation list for sidebar
setConversations(getAllConversations())
}
}, [messages, summary, conversationType, currentConversationId])
// πŸ†• LOCAL STORAGE: Conversation management functions
const startNewConversation = () => {
setCurrentConversationId(generateId())
setMessages([])
setSummary(null)
setConversationType('general')
}
const loadConversationById = (conversationId) => {
const conversation = loadConversation(conversationId)
if (conversation) {
setCurrentConversationId(conversationId)
setMessages(conversation.messages || [])
setSummary(conversation.summary || null)
setConversationType(conversation.conversationType || 'general')
console.log(`βœ… Loaded conversation: ${conversation.title}`)
}
}
const deleteConversationById = (conversationId) => {
deleteConversation(conversationId)
setConversations(getAllConversations())
if (currentConversationId === conversationId) {
startNewConversation()
}
}
// Helper functions
const createAiPlaceholder = () => {
const aiMessageId = Date.now() + 1
const aiMessage = {
text: "",
isUser: false,
id: aiMessageId,
isStreaming: true,
}
setMessages(prev => [...prev, aiMessage])
return aiMessageId
}
const readStream = async (response, aiMessageId) => {
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
setMessages(prev =>
prev.map(msg =>
msg.id === aiMessageId
? { ...msg, text: msg.text + chunk }
: msg
)
)
}
}
const sendMessage = async () => {
if (!input.trim() || isStreaming) return
// πŸ†• LOCAL STORAGE: Create new conversation if none exists
if (!currentConversationId) {
setCurrentConversationId(generateId())
}
const userMessage = { text: input.trim(), isUser: true, id: Date.now() }
setMessages(prev => [...prev, userMessage])
const currentInput = input
setInput('')
setIsStreaming(true)
const aiMessageId = createAiPlaceholder()
try {
const conversationHistory = buildConversationHistory(messages)
// SUMMARY MEMORY: Smart summary timing - happens in background
if (shouldCreateSummary(conversationHistory) && isGoodTimeToSummarize(conversationHistory)) {
const messagesToSummarize = conversationHistory.slice(0, -recentWindowSize);
createSummary(messagesToSummarize);
} else if (shouldUpdateSummary(conversationHistory) && isGoodTimeToSummarize(conversationHistory)) {
const messagesToSummarize = conversationHistory.slice(0, -recentWindowSize);
createSummary(messagesToSummarize);
}
abortControllerRef.current = new AbortController()
const response = await fetch('http://localhost:8000/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: currentInput,
conversationHistory: conversationHistory,
summary: summary,
recentWindowSize: recentWindowSize
}),
signal: abortControllerRef.current.signal,
})
if (!response.ok) throw new Error('Failed to get response')
await readStream(response, aiMessageId)
setMessages(prev =>
prev.map(msg =>
msg.id === aiMessageId ? { ...msg, isStreaming: false } : msg
)
)
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Streaming error:', error)
setMessages(prev =>
prev.map(msg =>
msg.id === aiMessageId
? { ...msg, text: 'Sorry, something went wrong.', isStreaming: false }
: msg
)
)
}
} finally {
setIsStreaming(false)
abortControllerRef.current = null
}
}
const stopStreaming = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
}
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey && !isStreaming) {
e.preventDefault()
sendMessage()
}
}
return (
<div className="h-screen bg-gray-100 flex">
{/* πŸ†• LOCAL STORAGE: Conversation Sidebar */}
{showSidebar && (
<div className="w-80 bg-white border-r border-gray-200 flex flex-col">
{/* Sidebar Header */}
<div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-800">Conversations</h2>
<button
onClick={() => setShowSidebar(false)}
className="text-gray-400 hover:text-gray-600"
>
βœ•
</button>
</div>
<button
onClick={startNewConversation}
className="w-full bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600 flex items-center justify-center space-x-2"
>
<Plus className="w-4 h-4" />
<span>New Chat</span>
</button>
</div>
{/* Conversation List */}
<div className="flex-1 overflow-y-auto">
{Object.values(conversations)
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
.map(conversation => (
<div
key={conversation.id}
className={`p-3 border-b border-gray-100 cursor-pointer hover:bg-gray-50 ${
currentConversationId === conversation.id ? 'bg-blue-50 border-l-4 border-l-blue-500' : ''
}`}
onClick={() => loadConversationById(conversation.id)}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-gray-800 truncate">
{conversation.title}
</h3>
<div className="flex items-center space-x-2 mt-1">
<span className="text-xs text-gray-500">
{conversation.messageCount} messages
</span>
{conversation.summary && (
<span className="text-xs bg-green-100 text-green-600 px-2 py-0.5 rounded">
πŸ“‹ Summary
</span>
)}
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-0.5 rounded">
{conversation.conversationType}
</span>
</div>
<p className="text-xs text-gray-400 mt-1">
{new Date(conversation.timestamp).toLocaleDateString()}
</p>
</div>
<button
onClick={(e) => {
e.stopPropagation()
deleteConversationById(conversation.id)
}}
className="text-gray-400 hover:text-red-500 ml-2"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))
}
{Object.keys(conversations).length === 0 && (
<div className="p-6 text-center text-gray-500">
<MessageSquare className="w-8 h-8 mx-auto mb-2 text-gray-300" />
<p className="text-sm">No conversations yet</p>
<p className="text-xs">Start a new chat to begin</p>
</div>
)}
</div>
</div>
)}
{/* Main Chat Area - Fixed Layout */}
<div className="flex-1 flex flex-col min-h-0">
{/* Chat Header - Fixed at top */}
<div className="bg-blue-500 text-white p-4 flex-shrink-0">
<div className="flex justify-between items-start">
<div className="flex items-center space-x-3">
{!showSidebar && (
<button
onClick={() => setShowSidebar(true)}
className="text-blue-100 hover:text-white"
>
<MessageSquare className="w-5 h-5" />
</button>
)}
<div>
<h1 className="text-xl font-bold">Summary Memory + Local Storage</h1>
<p className="text-blue-100 text-sm">
Smart conversation memory with persistent storage
</p>
</div>
</div>
<div className="text-right space-y-2">
<div>
<label className="block text-xs text-blue-100">Recent: {recentWindowSize}</label>
<input
type="range" min="5" max="30" value={recentWindowSize}
onChange={(e) => setRecentWindowSize(parseInt(e.target.value))}
className="w-20" disabled={isStreaming}
/>
</div>
<div>
<label className="block text-xs text-blue-100">Summary at: {summaryThreshold}</label>
<input
type="range" min="15" max="50" value={summaryThreshold}
onChange={(e) => setSummaryThreshold(parseInt(e.target.value))}
className="w-20" disabled={isStreaming}
/>
</div>
</div>
</div>
</div>
{/* Memory Status Dashboard - Fixed below header */}
<div className="bg-gray-50 px-4 py-3 border-b flex-shrink-0">
{(() => {
const { totalMessages, recentMessages, summarizedMessages } = getMemoryStats();
return (
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<div className="flex space-x-4 text-gray-600">
<span>πŸ“Š Total: {totalMessages}</span>
<span>πŸ”₯ Recent: {recentMessages}</span>
{summarizedMessages > 0 && (
<span>πŸ“ Summarized: {summarizedMessages}</span>
)}
<span className="text-blue-600">🧠 {conversationType}</span>
</div>
<div className="flex items-center space-x-2 text-xs">
{summary && (
<span className="text-green-600">βœ… Summary Active</span>
)}
{isCreatingSummary && (
<span className="text-blue-600">πŸ”„ Creating Summary...</span>
)}
<span className="text-purple-600">
πŸ’Ύ {Object.keys(conversations).length} saved
</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{
width: `${Math.min(100, (totalMessages / 50) * 100)}%`
}}
/>
</div>
</div>
);
})()}
</div>
{/* Active Summary Display - Fixed when present */}
{summary && (
<div className="bg-blue-50 border-l-4 border-blue-400 p-3 mx-4 mt-2 rounded flex-shrink-0">
<div className="flex items-start">
<span className="text-blue-600 mr-2">πŸ“‹</span>
<div className="flex-1">
<p className="text-xs font-medium text-blue-800 mb-1">
Active Summary ({conversationType}) β€’ Auto-saved
</p>
<p className="text-xs text-blue-700 leading-relaxed">
{summary}
</p>
</div>
</div>
</div>
)}
{/* Messages - ONLY this area scrolls */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 min-h-0">
{messages.length === 0 && (
<div className="text-center text-gray-500 mt-20">
<Bot className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p>Send a message to see Summary Memory + Local Storage in action!</p>
{Object.keys(conversations).length > 0 && (
<p className="text-sm mt-2 text-blue-600">
πŸ’Ύ Select a conversation from the sidebar or start a new one
</p>
)}
</div>
)}
{messages.map((message) => (
<div
key={message.id}
className={`flex items-start space-x-3 ${
message.isUser ? 'justify-end' : 'justify-start'
}`}
>
{!message.isUser && (
<div className="bg-blue-500 p-2 rounded-full">
<Bot className="w-4 h-4 text-white" />
</div>
)}
<div
className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${
message.isUser
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-800'
}`}
>
<MessageContent message={message} />
</div>
{message.isUser && (
<div className="bg-gray-500 p-2 rounded-full">
<User className="w-4 h-4 text-white" />
</div>
)}
</div>
))}
{/* Breathing room at bottom */}
<div className="h-4"></div>
</div>
{/* Input Area - Fixed at bottom */}
<div className="border-t bg-white p-4 flex-shrink-0">
<div className="flex space-x-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type your message..."
className="flex-1 border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isStreaming}
/>
{isStreaming ? (
<button
onClick={stopStreaming}
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-lg transition-colors"
>
Stop
</button>
) : (
<button
onClick={sendMessage}
disabled={!input.trim()}
className="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 text-white p-2 rounded-lg transition-colors"
>
<Send className="w-5 h-5" />
</button>
)}
</div>
</div>
</div>
</div>
)
}
export default App

What this complete component includes:

  • βœ… All Summary Memory features - Intelligent summarization, cost optimization, background processing
  • βœ… Complete localStorage integration - Auto-save, auto-restore, conversation management
  • βœ… Professional chat layout - Fixed input, scrollable messages, collapsible sidebar
  • βœ… Rich markdown support - Code blocks with copy buttons, formatting, links
  • βœ… Visual feedback - Memory stats, summary indicators, conversation metadata
  • βœ… User experience - Hover states, loading animations, error handling

Incredible work! πŸŽ‰ You’ve built a production-quality chat application that combines the best of modern AI memory management with persistent storage.

What you’ve accomplished:

  • πŸ’Ύ Persistent conversations that survive browser restarts
  • 🧠 Smart memory management with Summary Memory integration
  • πŸ“± Professional layout with fixed input and smooth scrolling
  • πŸ—‚οΈ Conversation management with sidebar, titles, and metadata
  • ⚑ Zero backend changes needed - pure frontend enhancement

You now understand:

  • πŸ”„ State persistence using browser localStorage APIs
  • 🎨 Modern chat UX with fixed layouts and professional design
  • 🧩 Complex state management coordinating multiple features seamlessly
  • πŸ—οΈ Progressive enhancement building on existing functionality

Your chat app now provides the complete experience users expect from modern AI applications - intelligent memory management, persistent storage, and a polished interface that rivals professional tools!

πŸ‘‰ Next: Database Storage Implementation - Ready to scale beyond localStorage? Learn how to add user accounts and cloud storage for multi-device, multi-user support!