Skip to main content
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.

Search input

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:
lib/chat/state.ts
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

lib/chat/types.ts
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):
lib/chat/state.ts
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

1

Check for existing query

If the query already exists in history, update its timestamp
2

Add new query

If it’s a new query, add it to the beginning of the list
3

Sort by recency

Sort history items by most recently used
4

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:
lib/chat/types.ts
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:
lib/chat/state.ts
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:
lib/chat/mock-data.ts
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)
    }
  }
}}
Clear the search by setting it to an empty string:
const clearSearch = useChatStore((state) => state.setSearchQuery)
clearSearch("")

Search scope

The current implementation searches:
  1. Contact names
  2. Last message previews
Future enhancements could include searching full message history, phone numbers, or message content within conversations.

Performance considerations

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

Build docs developers (and LLMs) love