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

# `lr-thinking-panel`

- **Import** `import '@aceshooting/lyra-ui/components/lr-thinking-panel.js';` (stable tag alias; registers the tag)
- **Class** `LyraThinkingPanel`, also available unregistered from `@aceshooting/lyra-ui/components/agent-tools/thinking-panel/thinking-panel.class.js`
- **Family** `components/agent-tools/` — 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** 6 parts, 13 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-thinking-panel`

A collapsible panel for an AI agent's intermediate reasoning/"thinking" transcript, kept visually
and semantically distinct from its final response. First-party invention (no Web Awesome
equivalent). Same collapsible header-button-plus-region shape as `<lr-source-list>`; the default
slot is entirely free-form (a consumer-composed `<lr-streaming-text>`, `<lr-markdown>`, or
plain text) — this component has no dependency on either.

**Properties:**

- `label?: string` — omitted localizes `thinkingPanelLabel` (`'Thinking'` in the built-in English
  catalog). Any supplied string is an explicit override and renders verbatim, including
  `label="Thinking"` under a non-English `.strings` catalog and `label=""`.
- `compact: boolean = false` (reflected) — tightens the header/body padding and the header's
  internal gap for dense transcript rows. This is only a density control: its card border and
  surface remain, so use `frame="plain"` when surrounding message chrome already supplies them.
- `frame: LyraFrame = 'card'` (reflected) — the library-wide container-frame vocabulary
  (`'card' | 'plain'`). `'card'` keeps the bordered, filled outer container. `'plain'` removes its
  border, background, and corner radius so a nested panel does not double an existing frame;
  it retains the header/body divider and the active regular or compact padding. The exported
  `ThinkingPanelAppearance` alias names this same union.
- `expanded: boolean = false` (reflected) — starts collapsed, matching `<lr-source-list>`'s
  default.
- `mode: 'live' | 'post-hoc' = 'live'` (reflected) — `'live'` while reasoning is actively streaming
  in; `'post-hoc'` once it's complete and being reviewed after the fact. Drives two concrete
  behavior differences, see prose below.
- `durationMs?: number` (attribute `duration-ms`) — how long the reasoning took. Omitted entirely
  (nothing rendered in `'post-hoc'`, a pulsing placeholder in `'live'`) while unset.
- `follow: boolean = true` (reflected) — whether live, expanded content follows the transcript tail.
  A user scroll updates this property and emits `lr-follow-change`; a direct assignment is
  controlled input and emits nothing. Being a `true`-defaulting boolean, `follow="false"` in plain
  HTML is honoured (it uses `trueDefaultBooleanConverter`), so the attribute form is load-bearing.

**Methods:** `scrollToBottom(): void` — scrolls `[part="body"]` to its current bottom immediately
(no smooth-scroll animation). Safe to call directly, e.g. from a host that wants to force a
jump-to-latest action of its own.

**Events:** cancelable `lr-toggle-request` (`detail: { expanded: boolean }`) fires before a header
activation changes state. Prevent it to retain the current `expanded` value. An accepted request
then updates `expanded` and emits the non-cancelable committed `lr-toggle` with the same detail;
vetoed requests never emit the committed event. `lr-follow-change` (`detail: { following: boolean }`)
reports user scroll release or re-engagement; direct `follow` assignments do not echo it.

**Slots:** default (the reasoning/thinking content; entirely free-form)

**CSS parts:** `base`, `header`, `label`, `duration`, `toggle`, `body`

`[part="body"]` is unconditionally `tabindex="0"`: it is a capped-height, independently scrollable
region whose content (plain text, a non-interactive `<lr-streaming-text>`) is often not focusable
itself, so without its own tab stop a keyboard user could never scroll it — the same convention
`<lr-code-block>`'s `[part="body"]` and `<lr-virtual-list>`'s `[part="base"]` follow. It therefore
carries both affordances that go with a real tab stop: an inward `--lr-focus-ring-*` outline while
`:focus-visible` (inward so the region's own `overflow` cannot clip it), and a subtler
`--lr-color-border` outline on pointer hover, so a mouse user also sees that the transcript is a
separately scrollable region.

**Themeable custom properties:** `--lr-thinking-panel-max-block-size` (default
`var(--lr-size-16rem)`, i.e. `16rem` — consumer-overridable cap on how tall `[part="body"]` grows
before it scrolls internally; not
exposed as a component property since it's a pure layout knob, not something a template branches
on), and `--lr-thinking-panel-pending-color` (default `var(--lr-color-brand)`) — the live-mode
pending duration/toggle accent without changing the shared brand token;
`--lr-thinking-panel-compact-header-padding` (default `var(--lr-space-2xs) var(--lr-space-s)`) —
`[part="header"]` padding while `compact`; `--lr-thinking-panel-compact-header-gap` (default
`var(--lr-space-2xs)`) — gap between the toggle, label, and duration while `compact`; and
`--lr-thinking-panel-compact-header-font-size` (default `var(--lr-font-size-sm)`) — font size of
`[part="header"]` while `compact`; and
`--lr-thinking-panel-compact-body-padding` (default `var(--lr-space-s)`) — `[part="body"]`
padding while `compact`. `--lr-thinking-panel-background` (default `var(--lr-color-surface)`),
`--lr-thinking-panel-border-color` (default `var(--lr-color-border)`) and
`--lr-thinking-panel-radius` (default `var(--lr-radius)`) retune `[part="base"]`'s card chrome
without a `::part(base)` override; the border-color hook also colors the header/body divider that
`frame="plain"` keeps. The mouse-hover preview on `[part="body"]` mentioned above has its own
four-longhand outline shape: `--lr-thinking-panel-body-hover-outline-width` (default
`var(--lr-focus-ring-width)`), `--lr-thinking-panel-body-hover-outline-style` (default `solid`),
`--lr-thinking-panel-body-hover-outline-color` (default `var(--lr-color-border)`, set to
`transparent` to opt out entirely), and `--lr-thinking-panel-body-hover-outline-offset` (default
`calc(-1 * var(--lr-focus-ring-offset))`). Unset, all four resolve to the rule's previous literal
paint. Plus shared
`--lr-color-border`/`-surface`/`-text`/`-text-quiet`/`-brand`/`-brand-quiet`,
`--lr-space-xs`/`-s`/`-m`, `--lr-radius`, `--lr-focus-ring-width`/`-color`/`-offset`,
`--lr-transition-fast`/`-base`.

**Optional peer deps:** none.

```html
<lr-thinking-panel label="Reasoning" mode="live" expanded>
  <lr-streaming-text
    content="Considering the user's constraints…"
    streaming
  ></lr-streaming-text>
</lr-thinking-panel>

<lr-thinking-panel label="Reasoning" mode="post-hoc" duration-ms="4200">
  <p>Finished reasoning, collapsed by default.</p>
</lr-thinking-panel>

<div class="message-frame">
  <lr-thinking-panel compact frame="plain" expanded>
    Reasoning nested inside message chrome without a second card frame.
  </lr-thinking-panel>
</div>
```

`mode` drives two concrete behavior differences, not just a styling hook. **Header hint:** while
`duration-ms` is unset, `'live'` shows a pulsing "Thinking…" placeholder in `[part="duration"]`;
`'post-hoc'` shows nothing there. Once `duration-ms` is set, both modes show the same static
"Thought for …" text. **Auto-scroll:** only `'live'` mode auto-follows new content appended to the
default slot while `expanded`; `'post-hoc'` never scrolls on its own.

Live-mode auto-scroll ("stick to bottom") is the classic chat-transcript convention: while
`mode="live"` and `expanded`, new content keeps the panel scrolled to its latest line — unless the
user has manually scrolled up to re-read earlier content (tracked via a `scroll` listener on
`[part="body"]`: every user-driven scroll records whether the body was left within 48px of its own
max scroll position, and only a mutation that arrives while that's still true triggers a follow-up
scroll). Opening an already-`'live'` panel — or a still-`expanded` panel later becoming `'live'` —
always resets this to "anchored" and jumps to the latest content. New content is detected two
ways, both feeding the same coalesced (at most one per animation frame) scroll-to-bottom:

- A `MutationObserver` on this element's own light DOM (`childList`+`subtree`+`characterData`),
  not `slotchange`, since a plain-text producer typically appends chunks to an existing node's
  `textContent` rather than re-slotting a whole new element per token. This cannot see a mutation
  that happens entirely inside a slotted custom element's own shadow root.
- A listener for `lr-content-settled`, a composed, bubbling, signal-only event this library's own
  streaming renderers (`<lr-streaming-text>`, `<lr-markdown>`, `<lr-markdown-core>`) emit at their
  own settle points. Being composed, it crosses exactly the shadow boundary the
  `MutationObserver` cannot, covering every property- or attribute-driven producer that renders
  into its own shadow root instead of mutating visible light-DOM text — which is exactly what the
  example above's `<lr-streaming-text content="…">` does, and why composing it there auto-follows
  correctly.

**Known gotchas:**

- The `MutationObserver` half only watches this element's own light-DOM subtree — it cannot see a
  mutation that happens entirely inside a slotted custom element's own shadow root (e.g. a
  `<lr-markdown>` re-rendering its shadow tree after a `content` change). That gap is exactly what
  the `lr-content-settled` listener covers for this library's own streaming renderers (see above).
  A bespoke slotted element that is neither a plain light-DOM text producer nor one of those three
  needs to append/mutate visible light-DOM text itself, emit its own `lr-content-settled`, or have
  the host call `scrollToBottom()` directly.
- Either half of the pair can trigger the jump-to-bottom/reset-stickiness behavior, as long as the
  _other_ half already holds: an `expanded` transition to `true` while `mode` is already `'live'`,
  **or** a `mode` transition to `'live'` while the panel is already `expanded`, both jump to the
  bottom and reset stickiness. Only a change that leaves the panel in some other combination
  (collapsed, or `mode !== 'live'`) skips it.
- The sticky-bottom flag starts `true` internally, so a panel that mounts already `expanded` and
  `mode="live"` follows its very first content mutation even before any `scroll` event has fired.

---
