# Conversation List

> A React chat history list for AI apps: New chat, search, pinned chats and day groups, with rename, pin, archive and delete.

- Type: React component (AI Kit)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add conversation-list`
- Docs: https://wingo-ui.com/components/conversation-list

## Interaction

Click or tap a chat to open it: the open one is tinted and keeps its ⋯ button, rows with an href navigate through your router link. Chats sit under sticky day labels (Pinned, Today, Yesterday, Previous 7 days, Previous 30 days, then one per month), newest first; a reply still streaming shows a small spinner and an unread chat a dot. With a mouse the ⋯ appears on hover or focus while the title fades out beneath it; on touch it is always there and the rows are 44px. The ⋯ menu (a bottom sheet on phones) renames, pins or unpins, archives and deletes, below any entries of your own; a long press on a touch screen opens the same menu (the sheet on a phone) with a haptic tick. Rename edits the title in place on a desktop (Enter or a click away saves, Escape cancels, an empty title keeps the old one) and on a phone in a small sheet with Save whose field has the keyboard up at once. Delete asks first, then the chat leaves at once and focus moves to the next one; pin moves a chat into Pinned while the rows around it slide (in one ungrouped list it glides up, lifted over the others). Every change shows right away, stays busy with a spinner while your handler's promise runs, and rolls back with an announcement if it rejects. Typing in the search turns the groups into one ranked list with the matched letters in bold (a match in the last message shows that part of it); Down steps from the search into the results. The rows are one Tab stop: Up and Down move, Home and End jump, Page Up and Page Down skip ten, Enter opens, F2 renames, Delete or Backspace deletes, Shift+F10 or the menu key opens the actions, and Tab reaches the focused row's ⋯. A title cut off or faded under the ⋯ shows in full as the row's tooltip after a moment with a mouse. Load more at the end also runs when it scrolls into view. Reduced motion turns every slide into a short fade.

## Installation

```bash
npx wingo-ui@latest add conversation-list
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/conversation-list` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/conversation-list.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Alert Dialog](https://wingo-ui.com/components/alert-dialog) ([Markdown](https://wingo-ui.com/components/alert-dialog.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) ([Markdown](https://wingo-ui.com/components/dropdown-menu.md))
- [Empty State](https://wingo-ui.com/components/empty-state) ([Markdown](https://wingo-ui.com/components/empty-state.md))
- [Input](https://wingo-ui.com/components/input) ([Markdown](https://wingo-ui.com/components/input.md))
- [Kbd](https://wingo-ui.com/components/kbd) ([Markdown](https://wingo-ui.com/components/kbd.md))
- [Search Input](https://wingo-ui.com/components/search-input) ([Markdown](https://wingo-ui.com/components/search-input.md))
- [Skeleton](https://wingo-ui.com/components/skeleton) ([Markdown](https://wingo-ui.com/components/skeleton.md))
- [Spinner](https://wingo-ui.com/components/spinner) ([Markdown](https://wingo-ui.com/components/spinner.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [useLongPress](https://wingo-ui.com/components/use-long-press) ([Markdown](https://wingo-ui.com/components/use-long-press.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [useShortcut](https://wingo-ui.com/components/use-shortcut) ([Markdown](https://wingo-ui.com/components/use-shortcut.md))
- [Date utils](https://wingo-ui.com/components/date-utils) ([Markdown](https://wingo-ui.com/components/date-utils.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Navigation](https://wingo-ui.com/components/navigation) ([Markdown](https://wingo-ui.com/components/navigation.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [Relative time](https://wingo-ui.com/components/relative-time) ([Markdown](https://wingo-ui.com/components/relative-time.md))
- [Text match](https://wingo-ui.com/components/text-match) ([Markdown](https://wingo-ui.com/components/text-match.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useState } from "react"
import { Share2 } from "lucide-react"
import { ConversationList, type ConversationItem } from "@/components/ui/conversation-list"

// your own wording over the English defaults (DEFAULT_CONVERSATION_LIST_LABELS)
const LABELS = {
  nav: "Assistant chats",
  pinned: "Starred",
  deleteTitle: (title: string) => `Delete “${title}” for good?`,
}

export function ChatHistory({ initial }: { initial: ConversationItem[] }) {
  const [chats, setChats] = useState(initial)
  const [open, setOpen] = useState(initial[0]?.id ?? "")
  const patch = (id: string, change: Partial<ConversationItem>) =>
    setChats((list) => list.map((chat) => (chat.id === id ? { ...chat, ...change } : chat)))

  return (
    <aside className="h-dvh w-72 bg-card">
      <ConversationList
        conversations={chats}
        value={open}
        onValueChange={setOpen}
        onNewChat={() => {
          const id = crypto.randomUUID()
          setChats((list) => [{ id, title: "", updatedAt: Date.now() }, ...list])
          setOpen(id)
        }}
        onRename={async (id, title) => {
          await fetch(`/api/chats/${id}`, { method: "PATCH", body: JSON.stringify({ title }) })
          patch(id, { title })
        }}
        onPinChange={(id, pinned) => patch(id, { pinned })}
        onArchive={(id) => patch(id, { archived: true })}
        onDelete={(id) => setChats((list) => list.filter((chat) => chat.id !== id))}
        getActions={(chat) => [{ id: "share", label: "Share", icon: <Share2 />, onSelect: () => navigator.clipboard.writeText(`/chat/${chat.id}`) }]}
        newChatShortcut="shift+mod+o"
        showTime
        locale="en-US"
        labels={LABELS}
      />
    </aside>
  )
}

// server search: control the text, show the server's results as they come, and keep them unfiltered
// <ConversationList conversations={results} searchValue={query} onSearchChange={setQuery} searching={pending} filter={false} />
// long histories: hasMore + onLoadMore (a promise keeps Load more spinning; it also runs when it scrolls into view)
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `conversations` | `ConversationItem[]` |  | The history: { id, title, updatedAt, preview?, pinned?, unread?, streaming?, archived?, href?, icon?, model? }. updatedAt (a Date, an ISO string or ms) sorts and groups them; a missing or invalid one goes to Earlier at the end. An empty title shows labels.untitled. Keep the objects immutable: a new object only for the chat that changed. |
| `value?` | `string` |  | The open conversation's id (controlled). Without it, rows with an href follow the current page of a LinkProvider (currentPath), and otherwise the last clicked row stays open. An id that is not in the list highlights nothing. |
| `defaultValue?` | `string` | `""` | The conversation open on the first render while uncontrolled. Empty in the playground opens Overdue invoices, September. |
| `onValueChange?` | `(id: string, item: ConversationItem) => void` |  | A row was chosen (a click, a tap or Enter). Rows with an href also navigate. |
| `onNewChat?` | `() => void` |  | Shows the New chat button at the top (and makes newChatShortcut work). |
| `onRename?` | `(id: string, title: string) => (void \| Promise<unknown>)` |  | Enables Rename in the menu, F2 and a double click with a mouse. The new title (trimmed, never empty) shows at once; a returned promise keeps the row busy, and a rejection brings the old title back and announces the error. |
| `onPinChange?` | `(id: string, pinned: boolean) => (void \| Promise<unknown>)` |  | Enables Pin / Unpin. The chat slides into (or out of) Pinned at once and rolls back if the promise rejects. |
| `onArchive?` | `(id: string) => (void \| Promise<unknown>)` |  | Enables Archive. The chat leaves the list at once (unless showArchived) and comes back if the promise rejects. |
| `onDelete?` | `(id: string) => (void \| Promise<unknown>)` |  | Enables Delete (the menu, Delete or Backspace on a focused row). After the confirmation the chat leaves at once and focus moves to the next chat, or New chat; a rejection brings it back. |
| `getActions?` | `(item: ConversationItem) => MenuEntry[]` |  | Extra dropdown-menu entries above the built-in ones (Share, Move to project, a submenu). Called each time a menu opens, so they can depend on the app's state. |
| `search?` | `boolean` | `true` | The search field under New chat. Its text still filters the list when you hide it and pass searchValue yourself. |
| `searchValue?` | `string` |  | The search text (controlled). Pair it with onSearchChange for a server search, then pass the results as conversations and filter={false}. |
| `defaultSearchValue?` | `string` | `""` | The search text on the first render while uncontrolled. |
| `onSearchChange?` | `(value: string) => void` |  | Every keystroke in the search, and the clear. |
| `searching?` | `boolean` | `false` | The search icon turns into a spinner while your server looks for results. |
| `searchShortcut?` | `string` | `""` | A key combo in mod+k notation that focuses the search from anywhere, shown as a hint in the empty field. "" binds nothing. |
| `filter?` | `boolean` | `true` | Filters titles, previews and model names locally while searching (case and diacritics ignored, best matches first). false keeps conversations as given, for results that come from a server. |
| `groupBy?` | `"date" \| "none"` | `"date"` | date: Pinned, Today, Yesterday, Previous 7 days, Previous 30 days, then one group per month, each under a sticky label. none: one list, pinned chats first with a pin glyph. |
| `showPreview?` | `boolean` | `false` | A second line with the last message; rows grow to 52 / 56px. |
| `showTime?` | `boolean` | `false` | A trailing time: "4:35 PM" today, "Yesterday" yesterday, "Sep 22" this year, the full date before. With a mouse the ⋯ takes its place on hover. |
| `showArchived?` | `boolean` | `false` | Lists archived chats too, muted and with an archive glyph (the demo has one from August). |
| `size?` | `"sm" \| "md"` | `"md"` | Rows 36 / 40px (44px on touch), with New chat and the search at the same height. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"md"` | The rows' corners, the button's radius at the row size (8 / 10px at md); full makes pills. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | Colors the open row (a gray tint for neutral, the tone's soft fill otherwise), the unread dot and the streaming spinner. |
| `color?` | `string` | `""` | Any CSS color. Replaces the tone for the open row, the dot and the spinner. Leave empty to use the tone. |
| `actionsVisibility?` | `"hover" \| "always"` | `"hover"` | hover: with a mouse the ⋯ shows on hover and keyboard focus (it is always there on touch and on the open chat). always: on every row, which keeps room for it. |
| `confirmDelete?` | `boolean` | `true` | Asks in an alert dialog before onDelete. false deletes at once. |
| `newChatShortcut?` | `string` | `""` | A key combo in mod+k notation that calls onNewChat from anywhere, shown on the button (⇧⌘O on a Mac, Ctrl+Shift+O elsewhere) and hidden on touch. "" binds nothing; the demo uses shift+mod+o. |
| `loading?` | `boolean` | `false` | Skeleton rows with the real row shape (two lines with showPreview) and aria-busy, while the history loads. |
| `skeletonCount?` | `number` | `8` | How many skeleton rows loading shows. |
| `hasMore?` | `boolean` | `false` | Load more at the end of the list; it also runs onLoadMore when it scrolls into view (once per new page, never while searching). |
| `onLoadMore?` | `() => (void \| Promise<unknown>)` |  | Loads the next page of older chats; a returned promise keeps Load more spinning until it settles. |
| `loadingMore?` | `boolean` | `false` | Keeps Load more spinning, for apps that track the request themselves. |
| `emptyState?` | `ReactNode` |  | Replaces the empty state (a chat icon, labels.empty and labels.emptyDescription). A search with no match shows its own, with Clear search. |
| `linkComponent?` | `(props: LinkComponentProps) => ReactNode` |  | Renders rows that have an href: next/link's Link, react-router's Link. Defaults to the LinkProvider's, then a plain <a>. |
| `now?` | `Date \| number` |  | The reference time for the day groups and times; default useNow(), which ticks each minute. Without it the list shows skeleton rows on the server and during hydration, so pass a server time to render the real list there. |
| `locale?` | `string` | `""` | Formats times, month groups and the result count ("en-US"). Empty uses the UIProvider's locale, then the browser's. |
| `labels?` | `Partial<ConversationListLabels>` |  | Every visible and spoken string, English by default (DEFAULT_CONVERSATION_LIST_LABELS): group names, the menu, the delete confirmation, the rename sheet, the empty states and the announcements. Counted and titled ones are functions: results(count, formatted), actions(title), deleteTitle(title), renamed(title). Pass a stable object. |
| `classNames?` | `Partial<Record<"header" \| "newChat" \| "search" \| "scroller" \| "group" \| "groupLabel" \| "list" \| "item" \| "row" \| "icon" \| "title" \| "preview" \| "indicator" \| "time" \| "actions" \| "rename" \| "more" \| "empty" \| "skeleton", string>>` |  | Extra classes for the inner parts. |
| `className?` | `string` |  | Extra classes for the root <nav>. It inherits its parent's background so the sticky day labels cover the rows; give it one (bg-card) when the parent is transparent. |

## Parts

### ConversationListItem (component)

One row on its own (a recent-chats card, a search result): item, active, onSelect, size, radius, tone, color, showPreview, showTime, now, locale, linkComponent, labels, classNames. Same look and link behavior, no menu; render it inside a <ul>. Props: ConversationListItemProps.

### groupConversations (function)

The grouping the list uses, pure: [{ key: "pinned" | "today" | "yesterday" | "week" | "month" | "2026-08" | "earlier", label, items }], newest first inside each group, month labels like "August 2026". Signature: (items: ConversationItem[], options: { now: Date | number; locale?: string; labels?: Partial<ConversationListLabels> }) => ConversationGroup[].

### DEFAULT_CONVERSATION_LIST_LABELS (constant)

The English strings, to spread a translation over. Type: ConversationListLabels.

### ConversationItem (type)

One chat: id, title, updatedAt (sorts and groups the list), preview, pinned, unread, streaming, archived, href (renders the row as a link), icon and model.

### ConversationGroup (type)

One group from groupConversations: key ("pinned", "today", a month such as "2026-08"...), label and items.

## Examples

- **In the app sidebar**: Inside the sidebar under the workspace switcher; rows are router links and the open page is highlighted.
- **With previews and times**: showPreview and showTime: the last message under each title and a trailing 4:35 PM / Yesterday / Sep 22.
- **Server search**: A controlled searchValue, filter={false} and a spinner in the field while the request runs.
- **Infinite**: 100 generated chats in pages of 20 with hasMore and onLoadMore; older ones group by month.
- **Empty and loading**: Skeleton rows while the history loads, and an empty state that the first chat replaces.
- **Phone**: A screen of its own at 350px: 44px rows, the ⋯ always visible, a long press opens the action sheet.
- **Tones and colors**: Brand for the open chat and the unread dot; a custom violet with small pill rows and actions always shown.
- **Recent chats, one row at a time**: ConversationListItem in a card of its own: default labels, no menu.

## See also

- [Sidebar](https://wingo-ui.com/components/sidebar) ([Markdown](https://wingo-ui.com/components/sidebar.md))
- [List](https://wingo-ui.com/components/list) ([Markdown](https://wingo-ui.com/components/list.md))
- [Search Input](https://wingo-ui.com/components/search-input) ([Markdown](https://wingo-ui.com/components/search-input.md))
- [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) ([Markdown](https://wingo-ui.com/components/dropdown-menu.md))
- [AI Chat](https://wingo-ui.com/components/ai-chat) ([Markdown](https://wingo-ui.com/components/ai-chat.md))

---

Canonical: https://wingo-ui.com/components/conversation-list
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
