kai-conversations
Groups the history by whatever you group on, with search built in.
- Shadow DOM
- 3 events
- Flat or grouped lists
- Built-in search
- Declarative child elements
Preview
Section titled “Preview”Set the data in JavaScript (it’s arrays and objects) and listen for CustomEvents:
<kai-conversations id="sidebar" style="display:block; width:300px; height:100vh;"></kai-conversations>
<script type="module"> import '@kitn.ai/ui/web-components';
await customElements.whenDefined('kai-conversations');
const el = document.getElementById('sidebar');
el.conversations = [ { id: 'c-1', title: 'Web component architecture', scope: { type: 'collection' }, messageCount: 12, lastMessageAt: '2026-06-16T10:00:00.000Z', updatedAt: '2026-06-16T10:00:00.000Z', }, ]; el.activeId = 'c-1';
el.addEventListener('kai-conversation-select', (e) => { el.activeId = e.detail.id; // keep the active highlight in sync }); el.addEventListener('kai-new-chat', () => console.log('new chat')); el.addEventListener('kai-toggle-sidebar', () => console.log('toggled'));</script>Two data approaches:
- In JavaScript —
el.conversationsis the rows;el.groupsis the section headers. They work together, not as alternatives: each row lands in the group whoseidmatches itsgroupId, and anything with no match falls into a single Ungrouped section. Leavegroupsempty and the whole list renders as one Ungrouped section. - Declarative children — nest
<kai-conversation>tags directly; each carries itsidas an attribute and title as text content. The element reads them on mount and watches via MutationObserver — works in plain HTML, SSR, or any framework that sets innerHTML.
| Child element | Attributes | Text content | Notes |
|---|---|---|---|
| <kai-conversation> | group-idid | Yes | Parse a single light-DOM `<kai-conversation>` element into a `ConversationSummary`. Attribute mapping: - `id` → ConversationSummary.id - `group-id` → ConversationSummary.groupId (optional) - textContent → ConversationSummary.title Fields not expressible as HTML attributes are NOT fabricated: the optional `scope` and `lastMessageAt` stay absent, and the required `messageCount`/`updatedAt` get honest defaults: zero messages, and an empty `updatedAt` from which no trailing relative time is derived (the epoch it used to fabricate rendered a bogus "many days ago" on every declarative row). |
Examples
Section titled “Examples”Default List
Section titled “Default List”A flat conversations array with no groups — every row renders in one Ungrouped section.
Active Conversation Highlighted
Section titled “Active Conversation Highlighted”activeId highlights the matching row and scrolls it into view.
Pre-bucketed Groups
Section titled “Pre-bucketed Groups”Supply groups for the section headers and give each conversation a matching groupId; anything unmatched falls to the “Ungrouped” section.
Empty State
Section titled “Empty State”An empty conversations array renders the built-in empty state.
Declarative Children
Section titled “Declarative Children”Nested <kai-conversation> elements — no JavaScript wiring needed.
Item mode: bring your own rows
Section titled “Item mode: bring your own rows”Slot <kai-conversation-item> children into the list and the loop is yours: framework-native map, <For>, or v-for over your own records. The container detects the children, skips its data rendering, and runs the assembly contract over them: selection state flowing container to item, roving tabindex, and the accessible list semantics (each row is a listitem whose body is the activation button, with aria-current marking the active one, the same dialect the built-in rows speak). kai-conversation-select stays the selection event in both modes.
// React: your records, your loop, your menu.<Conversations activeId={activeId} onConversationSelect={(e) => setActiveId(e.detail.id)}> {threads.map((t) => ( <kai-conversation-item key={t.id} conversation-id={t.id}> {t.title} <span slot="meta">{t.lastReplyAgo}</span> <MyThreadMenu slot="menu" thread={t} /> </kai-conversation-item> ))}</Conversations><!-- Plain HTML works the same way. --><kai-conversations id="sidebar"> <kai-conversation-item conversation-id="c-1"> Web component architecture <span slot="meta">2h ago</span> <button slot="menu" aria-label="Actions">⋮</button> </kai-conversation-item></kai-conversations>Each item takes a default slot for the title plus three named slots: leading (an icon or avatar), meta (a timestamp or status line), and menu. The menu slot takes your own popover, and that is the point: rename, fork, and archive are entries in YOUR menu, wired to YOUR state, not a declarative actions prop. A click inside the menu never selects the row.
Keyboard order: the row bodies share a single roving tab stop (Tab enters at the active row, arrow keys move between rows), and each row’s menu trigger keeps its natural place in the tab order, directly after its row. The menu sits BESIDE the activation body in the accessibility tree, never inside it, so your trigger stays reachable without tripping accessibility checkers on nested controls.
Standalone rows. Outside <kai-conversations> — a hand-rolled rail, or any wrapper that isn’t the container itself — a <kai-conversation-item> activates itself: its body is a tabbable button and click, Enter or Space fire kai-select on the item with { id } in the detail (non-bubbling, so listen on the row). What standalone rows don’t get is the container’s list story — roving tabindex and arrow-key traversal stay yours. Inside the container nothing changes: kai-conversation-select on the container is the one activation event, and the item stays silent.
The batteries boundary. With item children present, the built-in search filter, grouping, and empty state do not apply to your rows: your loop owns them. The chrome still renders (header, search box, new-chat, footer slots) and kai-search still fires, so filter your own loop off the event. The conversations array is ignored while any item child is present, and data rows return when the last one leaves.
Replace the title bar or the empty state, or add a footer row (account, settings, usage). The slot names mirror <kai-chat>, so the two compose with one vocabulary.
In batteries mode the built-in filter decides loudly: A search query that matches nothing shows a visible “No conversations match your search” state, distinct from the zero-conversations empty state.
A note on the row shape: updatedAt drives the auto relative time on each row (lastMessageAt is its fallback), and the list never reads scope. It exists for scope-aware consumers.
| Slot | Mode | Purpose |
|---|---|---|
| (default) | inject | Your own `<kai-conversation-item>` rows (item mode: the consumer-owned loop). Data rows do not render while any are present. |
| header | replace | Full custom title bar; replaces the built-in toggle / "Chats" / New-chat row. |
| empty | replace | Custom zero-state shown when there are no conversations; replaces the built-in "No conversations yet". |
| footer | inject | A row below the list: account, settings, or usage. |
| Property | Type | Default | Notes |
|---|---|---|---|
| theme | "light" | "dark" | "auto" | 'auto' | Color mode (`auto` follows prefers-color-scheme). |
| groups | [] | The list's section headers (`{ id, name, sortOrder, createdAt }`) in array order. JS property; omit for an ungrouped list. | |
| conversations | [] | The conversations to render, flat. JS property; omit to pass `<kai-conversation>` light-DOM children instead, or for the empty state. | |
| activeId | string | — | The id of the currently-open conversation, highlighted in the list. |
| collapsed | boolean | — | Controlled collapsed state (`el.collapsed = true`). Omit for uncontrolled; collapsed shrinks the rail to a reopen button. |
| defaultCollapsed | boolean | — | Initial collapsed state when uncontrolled (default false). Use the `default-collapsed` attribute to start collapsed in plain HTML. |
| compact | boolean | — | Dense single-line rows (a leading dot + title, no message count). |
| density | "default" | "compact" | "panel" | — | Row density: `default`, `compact` (same as the `compact` flag), or `panel` (the widget-panel row box). |
| searchable | boolean | true | Show the built-in search box above the list. Default `true`; `searchable="false"` hides it. |
Events
Section titled “Events”| Event | Detail | Notes |
|---|---|---|
| kai-collapse-toggle | | The rail was collapsed or expanded (via the toggle, the reopen button, or a `collapse()`/`expand()`/`toggle()` call). |
| kai-conversation-select | | A conversation was selected. The selection event in BOTH modes: a batteries data row, or an activated `<kai-conversation-item>` child (click, Enter or Space). |
| kai-new-chat | Record<string, never> | The "New chat" button was clicked. |
| kai-search | | The built-in search box query changed (typing, or a programmatic `clear()` which fires it with `''`). Lets a consumer mirror or server-side the filter. |
| kai-toggle-sidebar | Record<string, never> | The sidebar toggle was clicked. |
Methods
Section titled “Methods”| Method | Signature | Notes |
|---|---|---|
| focus | (options?: FocusOptions): void | Focus the built-in search input inside the shadow root. |
| clear | (): void | Clear the internal search query (resets the list filter) and fire kai-search with an empty string. |
| select | (id: string): void | Programmatically select a conversation by id. The mirror of the kai-conversation-select event (a convenience over driving `activeId`). |
| collapse | (): void | Collapse the rail to its floating reopen button (fires `kai-collapse-toggle`). |
| expand | (): void | Expand the rail back to the full list (fires `kai-collapse-toggle`). |
| toggle | (): void | Toggle the rail collapsed/expanded (fires `kai-collapse-toggle`). |
Composed from
Section titled “Composed from”This element wraps these SolidJS components — reach for them directly when you need finer control than the props expose.