The search system provides real-time filtering of chats and contacts with automatic history tracking for frequently used queries.
Search functionality
Search filters both contact names and message previews:
components/chat/sidebar.tsx
const filtered = useMemo(() => {
const query = searchQuery.trim().toLowerCase()
if (!query) {
return chats
}
return chats.filter((chat) => {
const contact = contacts[chat.contactId]
return (
contact?.name.toLowerCase().includes(query) ||
chat.lastMessagePreview?.toLowerCase().includes(query)
)
})
}, [chats, contacts, searchQuery])
Search is case-insensitive and matches against both contact names and the last message preview.
The search input is located in the sidebar header:
components/chat/sidebar.tsx
<div className="px-5 pb-3">
<div className="relative">
<MagnifyingGlass className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(event) => onSearchChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
const value = event.currentTarget.value.trim()
if (value) {
onSearchSubmit(value)
}
}
}}
className="h-10 rounded-full border border-sidebar-border bg-sidebar/30 pl-9 text-sm focus-visible:ring-2 focus-visible:ring-primary/40"
placeholder="Search chats"
/>
</div>
</div>
Search state management
Search query is managed in the chat store:
export interface ChatStore extends ChatStateSnapshot {
profile: Profile
searchQuery: string
showArchived: boolean
setActiveChat: (chatId?: string) => void
setSearchQuery: (value: string) => void
pushSearchHistory: (value: string) => void
// ...
}
setSearchQuery: (value) => {
set({ searchQuery: value })
}
Search history
The system tracks search queries for quick access to recent searches:
Search history interface
export interface ChatSearchHistoryItem {
id: string
value: string
lastUsedAt: string
}
Displaying search history
Recent searches appear as quick filter chips:
components/chat/sidebar.tsx
{searchQuery.length === 0 && searchHistory.length ? (
<div className="mt-3 flex flex-wrap gap-2">
{searchHistory.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onSearchChange(item.value)}
className="rounded-full bg-sidebar-accent px-3 py-1 text-xs font-medium text-sidebar-accent-foreground shadow-sm transition hover:bg-sidebar-accent/80"
>
{item.value}
</button>
))}
</div>
) : null}
Search history is only displayed when the search input is empty, providing quick access without cluttering the interface.
Adding to search history
When a user submits a search query (by pressing Enter):
pushSearchHistory: (value) => {
set((state) => {
const existing = state.searchHistory.find((item) => item.value === value)
const timestamp = new Date().toISOString()
if (existing) {
return {
...state,
searchHistory: state.searchHistory
.map((item) =>
item.id === existing.id ? { ...item, lastUsedAt: timestamp } : item
)
.sort((a, b) => (a.lastUsedAt > b.lastUsedAt ? -1 : 1)),
}
}
const id = generateMessageId("search")
return {
...state,
searchHistory: [
{ id, value, lastUsedAt: timestamp },
...state.searchHistory.slice(0, 4),
],
}
})
}
History behavior
Check for existing query
If the query already exists in history, update its timestamp
Add new query
If it’s a new query, add it to the beginning of the list
Sort by recency
Sort history items by most recently used
Limit history size
Keep only the 5 most recent searches
Search history is limited to 5 items to prevent overwhelming the UI with too many options.
Search state structure
Search history is part of the global chat state:
export interface ChatStateSnapshot {
chats: Record<string, Chat>
messages: Record<string, Message>
contacts: Record<string, Contact>
drafts: Record<string, DraftMessage>
typingIndicators: TypingIndicator[]
searchHistory: ChatSearchHistoryItem[]
activeChatId?: string
}
Empty search results
When no chats match the search query:
components/chat/sidebar.tsx
{filtered.length === 0 ? (
<div className="px-6 py-8 text-center text-sm text-muted-foreground">
No chats match your search.
</div>
) : null}
Search persistence
Search history is persisted to localStorage:
export const useChatStore = create<ChatStore>()()
persist(
(set, get) => ({
// store implementation
}),
{
name: "chat-store",
version: 1,
storage: createJSONStorage(() => (typeof window === "undefined" ? noopStorage() : localStorage)),
partialize: (state) => ({
chats: state.chats,
messages: state.messages,
contacts: state.contacts,
drafts: state.drafts,
searchHistory: state.searchHistory,
profile: state.profile,
activeChatId: state.activeChatId,
showArchived: state.showArchived,
}),
}
)
)
Search history is automatically saved to localStorage and restored on page reload.
Real-time filtering
The search updates in real-time as the user types:
<Input
value={searchQuery}
onChange={(event) => onSearchChange(event.target.value)}
// ...
/>
The filtered results are computed using useMemo for performance:
const filtered = useMemo(() => {
const query = searchQuery.trim().toLowerCase()
if (!query) {
return chats
}
return chats.filter((chat) => {
const contact = contacts[chat.contactId]
return (
contact?.name.toLowerCase().includes(query) ||
chat.lastMessagePreview?.toLowerCase().includes(query)
)
})
}, [chats, contacts, searchQuery])
Mock search history
Example search history from mock data:
const initialState: ChatStateSnapshot = {
// ...
searchHistory: [
{
id: nextId("search"),
value: "launch",
lastUsedAt: iso(subMinutes(baseTimestamp, 70)),
},
{
id: nextId("search"),
value: "feedback",
lastUsedAt: iso(subMinutes(baseTimestamp, 220)),
},
],
activeChatId: "chat-carlos",
}
Search UX features
Quick access from history
Clicking a history chip populates the search input:
<button
type="button"
onClick={() => onSearchChange(item.value)}
className="rounded-full bg-sidebar-accent px-3 py-1 text-xs font-medium text-sidebar-accent-foreground shadow-sm transition hover:bg-sidebar-accent/80"
>
{item.value}
</button>
Search submission
Pressing Enter adds the query to history:
onKeyDown={(event) => {
if (event.key === "Enter") {
const value = event.currentTarget.value.trim()
if (value) {
onSearchSubmit(value)
}
}
}}
Clearing search
Clear the search by setting it to an empty string:
const clearSearch = useChatStore((state) => state.setSearchQuery)
clearSearch("")
Search scope
The current implementation searches:
- Contact names
- Last message previews
Future enhancements could include searching full message history, phone numbers, or message content within conversations.
The search uses useMemo to avoid re-filtering on every render:
const filtered = useMemo(() => {
// filtering logic
}, [chats, contacts, searchQuery])
This ensures filtering only occurs when:
- The search query changes
- The list of chats changes
- The contacts object changes