<!-- GENERATED by scripts/build-llms.mjs from llms/conversation.md — do not edit this file. -->

# `lr-thread-list`

- **Import** `import '@aceshooting/lyra-ui/components/lr-thread-list.js';` (stable tag alias; registers the tag)
- **Class** `LyraThreadList`, also available unregistered from `@aceshooting/lyra-ui/components/conversation/thread-list/thread-list.class.js`
- **Family** `components/conversation/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.0.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
- **Deprecations** none
- **Optional peers** none
- **Themeable via** 41 parts, 20 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-thread-list`

The conversation sidebar: a grouped, searchable list of chat sessions with pin/archive/delete/rename
affordances. _Data mode_ (at least one valid `threads` record, or no valid records with nothing
slotted) renders every row as a `lr-conversation-item` inside an internal `lr-virtual-list` — virtualized by
construction, scroll position and per-row state survive a `threads` replacement; zero rows renders
the built-in empty state. _Slotted mode_ (no valid `threads` records _and_ real slotted content) renders
host-supplied `lr-conversation-item`s from the default slot as-is: no grouping, virtualization, or
row actions in that mode. No thread CRUD or persistence — every mutation
(`lr-thread-pin`/`-archive`/`-delete`/`-rename`) is a controlled event carrying the _requested_ new
state; the host mutates `threads`.

A conversation row with `slot=""` belongs to the default slot exactly like a row with no slot
attribute, including after reconnect. Named slots retain their distinct roles.

ArrowUp/ArrowDown/Home/End navigation skips rows that are disabled, hidden, `aria-hidden`, or
`inert` (including an inert ancestor introduced by `wrapRow`). Arrow navigation continues through
the complete item model at a virtual-window edge and mounts the next available row before moving
focus. Home/End always resolve the first/last thread from that complete model, even when focus
starts in a middle window; group records, collapsed-group contents, and unavailable endpoint rows
are skipped rather than becoming false boundaries.

**Exported types:** `LyraChatThread { id: string; title: string; excerpt?: string; timestamp?: Date |
string | number; pinned?: boolean; archived?: boolean }`; `ThreadRowAction = 'pin' | 'archive' |
'delete'`; `ThreadListGrouping = 'date' | 'custom' | 'none'`; `ThreadBucketKey = 'pinned' |
'today' | 'yesterday' | 'previous7' | 'previous30' | `month:${string}` | 'archived'`;
`ThreadGroupContext { id: string; threads: readonly LyraChatThread[]; bucket?: ThreadBucketKey;
date?: Date }`; and `ThreadGroupToggleDetail { groupId: string; collapsed: boolean }` (the shared
payload type for the `lr-group-toggle-request`/`lr-group-toggle` pair). `LyraThreadList` and
`LyraThreadListEventMap` are exported alongside them. The class
module, normal and stable tag-shaped registration entries, conversation family entry, and package
root all retain this complete thread-list surface; the former `ChatThread` name is not retained.
Data-mode thread ids must be nonempty, nonblank, and unique, and every row must have a string
`title`; invalid rows and later duplicates are omitted with the first valid occurrence winning
before the mode is selected, so focus, actions, slot ownership, and emitted `conversationId`
values remain unambiguous.

Display fields are captured when `threads` is assigned. After a change, create and assign a new
collection; changing an assigned record or mutating the assigned array does not update the view.
`filter`, `groupBy`, row callbacks, and group contexts receive the original source records.

**Properties:** `threads: LyraChatThread[] = []` (attribute: false). `activeConversationId: string = ''`
(attribute `active-conversation-id`) — data mode:
marks the matching row `active`/`aria-current` and scrolls it into view. `searchable: boolean =
false` (reflected) — shows the built-in search field, including a `part="clear-button"` icon button
that appears next to it once it has a value (never when empty), clears it on click, fires the same
`lr-filter-change`/`lr-query-change` event typing already fires, and returns focus to the field. Its
accessible name is the localized `clear` message (the same key `<lr-input>`'s own clear button
uses). `size?: LyraSize` (reflected) — opt-in density tier for that search field, on the library's
one six-step ladder (`2xs`/`xs`/`s`/`m`/`l`/`xl`, or the Web Awesome/Shoelace `small`/`medium`/`large`
spellings, accepted as authored). A tier gives the field the row height, text size, gutters and
corner radius an `lr-input` of that tier has, so the sidebar's own filter box lines up with an
adjacent themed search field. With no `size` the field keeps exactly the gutters, corner radius and
inherited text size it shipped with, so existing markup renders unchanged; an unsupported value
normalizes to the omitted state and removes the attribute rather than snapping to a tier. Only the
field is tiered — the gutter around it and the clear button keep their own sizes, and have their own
custom properties. `filter?: (thread, query) => boolean`
(attribute: false) — overrides the default case-insensitive `title` + `excerpt` substring match.
`grouping: ThreadListGrouping = 'date'` — data mode: bucket rows under localized date headers
(Pinned/Today/Yesterday/Previous 7 days/Previous 30 days/one bucket per month/Archived), use the
arbitrary grouping callbacks below, or render a flat list. `groupBy?: (thread: LyraChatThread) => string`
(attribute: false) derives each group id in `grouping="custom"`; rows whose callback throws or
returns a malformed or blank ID are omitted from that grouped view, and omitting the callback leaves
custom mode flat. `getGroupLabel?: (context: ThreadGroupContext) => string` (attribute: false) supplies the
plain-text accessible/visible label; `renderGroupAdornment?: (context) => TemplateResult` supplies
separate rich content beside the toggle without nesting it inside the button. `groupOrder?: string[] | ((a: string, b:
string) => number)` (attribute: false) supplies an explicit order or comparator; ids omitted from an
array follow in first-seen order. `collapsedGroupIds: string[] = []` (attribute: false) is the
collapsed state for both date and custom groups, **self-managed by default**: activating the
built-in group toggle updates this array directly. A collapsed group's header remains in the
virtual list while its conversation rows are removed from the virtual-list item/measurement set.
Prevent the default of the cancelable `lr-group-toggle-request` event (see **Events** below) to
veto that write and keep this property fully controlled instead — the only behavior it had before
self-management existed. Group headers and threads use separate
internal key namespaces, so every public `activeConversationId` remains a raw thread id — even a value such as
`group:today` cannot collide with the `today` group header. `rowActions: ThreadRowAction[] = []`
(attribute: false, each `'pin' | 'archive' | 'delete'`) —
data mode only: built-in icon buttons rendered into each row's `actions` slot. `showArchived: boolean
= false` (attribute `show-archived`, reflected) — data mode: include `archived` threads (in their own
trailing group). `renamable: boolean = true` (reflected) — forwarded to each data-mode row's inline
rename. `compact: boolean = false` (reflected) — data mode only: forwarded to each row
`lr-conversation-item`'s own `compact`, tightening every row's padding and gaps from one attribute
(the density itself lives on the row item; retune it through
`--lr-conversation-item-compact-padding`/`-gap` on this element or any ancestor). Slotted mode is a
deliberate no-op — that mode renders host-supplied items as-is, so the host sets `compact` on its own
items there, the same division of responsibility slotted mode already has for every other row
property. `stickyGroups: boolean = false` (attribute `sticky-groups`, reflected) — data mode: pins
the current date/custom group's header to the top of the scroll viewport while its rows are in view,
pushing it off as the next group's header arrives. Group headers are ordinary virtualized rows, so
this renders an `aria-hidden` copy of the header into the internal `lr-virtual-list`'s sticky layer:
the real row keeps the `role="heading"`/`aria-level` semantics and its interactive toggle. The
pinned copy is inert, pointer-transparent presentation content: its toggle and adornments do not
receive interaction, and it adds no second tab stop. Default `false` renders exactly as before; `grouping="none"` has no
headers to pin, so it is a no-op there. `label?: string` — accessible name for the list region.
Omitting it uses the localized `threadListLabel`; an explicit empty string intentionally leaves the
list unnamed. `error: boolean = false` (reflected) — data mode only: reports a failed thread-list
load. While set, the built-in `<lr-empty>` failed-load state (matching `<lr-table>`'s own `error`
contract) replaces the virtual list/empty state; `error` beats the built-in empty state.
`errorHeading?: string` (`error-heading`) — failed-load heading override; omitted localizes the
same `tableLoadFailed` default `<lr-table>` uses. `errorDescription: string = ''`
(`error-description`) — failed-load supporting copy. `wrapRow?: (thread: LyraChatThread, row: TemplateResult) =>
TemplateResult` (attribute: false) — data mode only: wraps each row's built-in
`lr-conversation-item` with host-supplied content that has no home in the item's own `label`/`excerpt`/`meta`/`actions` surface (e.g. a leading purpose
icon — the item has no default slot to receive one); unset renders the built-in row unwrapped.
`renderActions?: (thread: LyraChatThread) => TemplateResult` (attribute: false) — data mode only:
appends host-supplied content (re-invoked per row on every render, e.g. an `lr-dropdown` containing `lr-menu` with custom
actions) after the built-in `rowActions` output in each row's `actions` slot; events it fires reach
the host normally and never trigger `lr-select`. An open nested `lr-dropdown` keeps its virtual row
above later rows even if focus temporarily leaves the menu. Unset renders only the built-in
`rowActions`.
`renderStart?: (thread: LyraChatThread) => TemplateResult` (attribute: false) — renders non-interactive
start-side content in each virtualized row. `renderExcerpt?: (thread: LyraChatThread) => TemplateResult`
(attribute: false) — renders rich content into the row item's own `excerpt` slot, winning over the
plain-string `excerpt` property (e.g. a server-highlighted search-match snippet), while leaving the
built-in label layout and inline-rename affordance untouched. `<mark>` descendants returned by this
hook receive the default, component-themeable highlight treatment documented below.
`renderMeta?: (thread: LyraChatThread) => TemplateResult` (attribute: false) — appends structured
metadata in the row's meta region.
`renderRowContent?: (thread: LyraChatThread) => TemplateResult` (attribute: false) — replaces the
conversation item's label/excerpt/meta content area with custom non-interactive row content.
`formatDate?: (date: Date) => string` (attribute: false) — overrides month-group date formatting.
Use `getGroupLabel` for every date/custom group label. When `wrapRow` is set, its
returned content is placed inside the library-owned `row-wrapper` part; that wrapper surrounds the
complete built-in row, including built-in `rowActions` and appended `renderActions` content inside
the conversation item's `actions` slot. Use `row-wrapper` for whole-row layout, `row-actions` for
the callback-output region, and the `row-item-*` parts for the conversation item's own internals.
With `wrapRow` unset, no wrapper element or `row-wrapper` part is rendered.

**Methods:**

- `itemElement(conversationId)` — the rendered `lr-conversation-item` for one thread's
  `conversationId` (data mode) or one slotted item's own `conversation-id` (slotted mode), or
  `null` when it is not currently rendered: filtered out by `showArchived`/search, windowed out of
  the virtualized viewport, removed from `threads`, or never present

`conversationId` is not a second identity scheme layered on top of the list — it is the same stable
id every row already carries on its own public `conversation-id` attribute/property, which every
row event (`lr-select`, `lr-thread-pin`, `lr-thread-archive`, `lr-thread-delete`,
`lr-thread-rename`) already keys off. `itemElement()` documents an accessor for it instead of a
consumer piercing this component's shadow root (and, in data mode, the nested internal
`lr-virtual-list`'s own shadow root) to walk rendered rows the way this component's own internals
do. It reads the DOM as it stands, so `await threadList.updateComplete` first and treat `null` as
"not rendered right now" — in particular, a data-mode row can exist in `threads` yet still return
`null` while it is scrolled outside the virtualized window.

**Slots:** default — slotted mode only: host-supplied `lr-conversation-item`s, rendered in order.
`empty` — replaces the built-in empty state. `error` — replaces the built-in failed-load state,
including its retry button, while `error` is set.

**Events:** data mode: `lr-select` (`detail: { conversationId }`), `lr-thread-pin`
(`detail: { conversationId, pinned }` — the requested new state), `lr-thread-archive`
(`detail: { conversationId, archived }`), `lr-thread-delete` (`detail: { conversationId }`, no
built-in confirmation), `lr-thread-rename` (`detail: { conversationId, label }`, correlated and
re-emitted from the owned row), `lr-filter-change` (`detail: { text, matchCount }`). Slotted mode
instead emits `lr-query-change` (`detail: { text }`) and never claims a match count it cannot own.
`lr-group-toggle-request` (`detail: { groupId, collapsed }`, cancelable) — proposed before a
custom/date group's collapse state changes; calling `preventDefault()` skips the built-in
`collapsedGroupIds` write and suppresses the following `lr-group-toggle`, leaving the group's
collapse state fully controlled. `lr-group-toggle` (`detail: { groupId, collapsed }`) — the
change was accepted and, unless `lr-group-toggle-request` was prevented, already applied to
`collapsedGroupIds`; native group buttons provide Enter/Space activation and explicit
`aria-expanded="true"|"false"` regardless. A consumer that already listens for `lr-group-toggle`
and reassigns `collapsedGroupIds` itself keeps working unchanged: this component's own write, when
it happens, always precedes that listener in the same synchronous dispatch, so the host's own
assignment simply wins last. `searchable` only: `blur`/`focus` (no detail) — re-dispatched from
the internal search `<input>`'s own `blur`/`focus`, bubbling and composed unlike the native events,
which are neither. `lr-retry` (`detail: null`, cancelable) — the built-in `[part='retry-button']`
was activated, only rendered while `error` is set; the default action clears `error`,
`preventDefault()` leaves it set instead.

**CSS parts:** `base`, `search`/`search-input` (the search field wrapper and `<input
type="search">`), `clear-button` (clears the search field; rendered only while it has a value,
mirroring `<lr-input>`'s own `clearable` contract's part name), `list` (the list region), `empty`,
`error` (the built-in `<lr-empty>` host rendered while `error` is set, with `error-base`/
`error-icon`/`error-heading`/`error-description`/`error-actions` exported from its own parts, plus
`retry-button` for the built-in retry control), `viewport` (the actual internal virtual-list
scroll container, suitable for scrollbar styling), `row-action` (a built-in pin/archive/delete icon
button), `pin-glyph` (the small pin indicator on a pinned row), `group-header`, `group-toggle`,
`group-label`, `group-adornment`, `group-icon`, `group-sticky` (`sticky-groups` only: the pinned copy of the current
group's header, exported from the internal `lr-virtual-list`'s sticky layer — it wraps a full copy of
the `group-header`/`group-toggle`/`group-label`/`group-adornment`/`group-icon` markup, so those parts style the real
header row and the pinned copy alike, and the band itself is where a shadow or bottom border
belongs), `row` (all exported across the internal `lr-virtual-list` shadow
boundary), `row-wrapper` (the wrapper around `wrapRow` output, only present when `wrapRow` is set;
row-only — group headers are never passed through `wrapRow`, so they never carry it), and
`row-start`/`row-excerpt`/`row-content`/`row-meta`/`row-actions` (the library-owned wrappers around
their corresponding render-hook output; inherited fonts, layout values, and theme custom properties
reach callback-rendered descendants through these parts). `row-excerpt` wraps `renderExcerpt`
output, which is slotted into the row item's own `excerpt` slot.

Data mode additionally forwards each row `<lr-conversation-item>`'s own parts under a `row-item-`
prefix: `row-item-base`, `row-item-active-indicator`, `row-item-select-button`, `row-item-start`, `row-item-content`,
`row-item-label`, `row-item-label-input`, `row-item-rename-button`, `row-item-excerpt`,
`row-item-meta`, `row-item-timestamp`, `row-item-actions`.

**Themeable excerpt highlights:** `<mark>` descendants returned by `renderExcerpt` use
`--lr-thread-list-excerpt-highlight-background` (default `var(--lr-color-warning-quiet)`),
`--lr-thread-list-excerpt-highlight-foreground` (default `inherit`),
`--lr-thread-list-excerpt-highlight-radius` (default `var(--lr-radius-xs)`), and
`--lr-thread-list-excerpt-highlight-padding` (default `0`). These properties inherit through the
internal virtual-list shadow tree, so set them on `lr-thread-list` or any ancestor. They do not style
marks returned by `renderRowContent` or any other hook.

**Themeable control states:** `--lr-thread-list-group-toggle-hover-bg` (default
`var(--lr-color-surface-raised)`), `--lr-thread-list-group-toggle-hover-color` (default
`var(--lr-color-text)`), `--lr-thread-list-group-toggle-active-bg` (default `color-mix(in oklab,
var(--lr-thread-list-group-toggle-hover-bg, var(--lr-color-surface-raised)),
var(--lr-color-mix-partner) var(--lr-color-mix-active))`), and
`--lr-thread-list-group-toggle-active-color` (default
`var(--lr-thread-list-group-toggle-hover-color, var(--lr-color-text))`) style group-toggle hover
and pressed states. `--lr-thread-list-row-action-hover-bg` (default
`var(--lr-color-surface-raised)`), `--lr-thread-list-row-action-hover-color` (default
`var(--lr-color-text)`), `--lr-thread-list-row-action-active-bg` (default `color-mix(in oklab,
var(--lr-thread-list-row-action-hover-bg, var(--lr-color-surface-raised)),
var(--lr-color-mix-partner) var(--lr-color-mix-active))`), and
`--lr-thread-list-row-action-active-color` (default
`var(--lr-thread-list-row-action-hover-color, var(--lr-color-text))`) do the same for row actions.

**Themeable search geometry:** `--lr-thread-list-search-padding` (default `var(--lr-space-s)`) is
the gutter around the search row and `--lr-thread-list-search-gap` (default `var(--lr-space-xs)`)
the gap between the field and its clear button; neither follows `size`.
`--lr-thread-list-search-min-height` and `--lr-thread-list-search-font-size` are unset while `size`
is — the field is then as tall as its own text and inherits the ambient text size — and a tier
resolves them to that tier's shared form-control height and font size.
`--lr-thread-list-search-padding-inline` (default `var(--lr-space-s)`),
`--lr-thread-list-search-padding-block` (default `var(--lr-space-xs)`) and
`--lr-thread-list-search-radius` (default `var(--lr-radius)`) are replaced by the tier's shared
form-control values when `size` is set. `--lr-thread-list-search-clear-size` (default
`var(--lr-size-1-5rem)`) is the clear button's box; it never follows `size`, because that button is
a tap target floored at `--lr-icon-button-size` rather than a text box. Every one of these wins over
the tier, so a consumer can take a tier and then move one value.

**Keep the two prefixes straight — they are different surfaces.** The `row-*` parts wrap _this_
component's own render-callback output (`wrapRow`, `renderStart`, `renderExcerpt`,
`renderRowContent`, `renderMeta`, `renderActions`); the `row-item-*` parts are the row item's
_internals_. Row density
in particular lives in `row-item-base`'s padding and `row-item-label`'s font size, so
`::part(row-item-base)` is the supported way to build a dense sidebar.

For plain row density, prefer the `compact` property above — it forwards straight to the row item's
own density knob. The `row-item-*` parts remain the lever for tuning beyond it (a different font
size, a different padding ratio):

```css
lr-thread-list::part(row-item-base) {
  padding-block: 0.25rem;
}
lr-thread-list::part(row-item-label) {
  font-size: 0.8125rem;
}
```

Do **not** reach for `::part(row) { --lr-theme-space-s: … }` instead. That is a whole-subtree
retheme: it shrinks everything nested inside the row, including the items of a `renderActions` menu,
which pushes their touch targets below the accessible minimum. The `row-item-*` parts exist so row
density can be tuned without that blast radius.

**Sizing:** the internal list fills whatever height this component is given, with no consumer CSS —
`[part='viewport']` is the real scroll container, and it falls back to `lr-virtual-list`'s own `24rem`
default only when the container has no resolvable height. This is deliberately _not_ implemented by
setting `--lr-virtual-list-height: 100%`: that percentage resolves against this host, which is a flex
item, so in an auto-height container it chains to `auto` and the viewport either collapses to zero
(with no rows) or grows to the full un-virtualized content height (with rows) — defeating
virtualization in both directions. Instead the list host is made a column flex container, which turns
the shipped `24rem` into a _flex-basis_: it grows to fill a bounded pane, shrinks below `24rem` in a
short one, and falls back to exactly `24rem` in an auto-height container.

`sticky-groups` keeps the current date group's header visible while scrolling through a long sidebar;
style the pinned band with `lr-thread-list::part(group-sticky)`.

```html
<lr-thread-list searchable sticky-groups></lr-thread-list>
<script type="module">
  const list = document.querySelector("lr-thread-list");
  list.threads = threads;
  list.activeConversationId = activeThreadId;
  list.rowActions = ["pin", "archive", "delete"];
  list.addEventListener("lr-select", (e) => openThread(e.detail.conversationId));
</script>
```

Composed with `lr-multi-split` (or `lr-app-rail` + `lr-responsive-panel`): thread-list in the start
pane driving `activeConversationId`, `lr-chat-viewport` + `lr-chat-composer` in the main pane.
