Chat

AI Chat component set — the building blocks for assistant interfaces. letbe-ds owns the chrome, layout, and state machine; consumers bring the model API, the streaming source, and any tools. Per the pluggable-engine HARD rule, nothing in this module wraps an LLM SDK or markdown engine. See js/components/README.md for the module pattern.

a11y (built in): the Thread is a live region (role="log" + aria-live="polite" + aria-relevant="additions text") and a streaming bubble sets aria-busy="true", so assistive tech gets one announcement of the finished message instead of every token — and the caret drops its blink under prefers-reduced-motion. The composer textarea carries aria-label="Message", Enter sends and Shift+Enter newlines behind an isComposing guard so IME commits never send mid-word; the slash palette is a role=listbox driven by ↑/↓, Enter, Esc, and inline edit takes Esc to cancel or ⌘/Ctrl+Enter to save. ConvList is a plain role=list — each row's title is the real button (aria-current marks the open conversation, so the kebab is a legitimate sibling control, not a nested one), navigated with ↑/↓, Home/End, Enter/Space, its kebab is a role=menu with roving tabindex that closes on Esc and returns focus to the trigger, and every ToolCall head is a button[aria-expanded].

Usage: Load js/lb.js first, then js/components/lb-chat.js — the module registers on window.LB and bails without it, and lb.js console-warns if it sees chat markup with no module loaded. Elements carrying data-lb-bubble / data-lb-thread / data-lb-composer / data-lb-tool-call / data-lb-conv-list auto-init; instances land at el._lbBubble, el._lbThread, el._lbComposer, el._lbToolCall and el._lbConvList, and a ToolCall can also be built on demand via new LB.ToolCall(el). The model loop is yours: on lb-composer-submit ({value, chips, tools, model}) call your API, append with appendBubble({…}), push tokens through appendText(), finish with setState('done') — and treat lb-composer-stop as halt-your-stream. ConvList never fetches: feed it with setConvs([…]) and route lb-conv-select / lb-conv-action to your backend.

This page documents the primitives — Bubble, Thread, Composer, branching, ToolCall, and ConvList. For the composed workspace that mounts them, see the AI Chat template (App Shell + Composer Dock).

Everything here works standalone in any layout you build: Bubble (assistant/user/system), Thread with visible branching, the Composer (Enter to send · footer action bar · chips · slash commands), ToolCall, dual-format code block, and the ConvList sidebar — plus the module pattern in js/components/README.md.


Bubble — Assistant

Flat text body, full content width, no card. The default variant for assistant messages. Avatar slot holds a small mark or icon; footer holds the action row (Copy / Like / Dislike / Regenerate) revealed on hover.

AI
Assistant 2:34 PM

The sky looks blue because sunlight scatters as it passes through the atmosphere — shorter blue wavelengths scatter far more than the longer red ones, so blue light reaches your eyes from every direction.

At sunrise and sunset the light crosses far more air, the blue is scattered away before it reaches you, and the reds and oranges are what remain.

Bubble — User

Right-aligned with a subtly tinted card body. Avatar appears on the right. Footer row holds Edit (which creates a branch) and a kebab for share / fork / delete. Long user messages cap at 75% width so they don’t span the full thread.

JS
You 2:33 PM
Why is the sky blue?

Bubble — System

Centred caption, no avatar, no actions. Used for status messages: “Chat title updated”, “Memory updated”, “Connection lost — reconnecting”, or day separators. Minimal visual weight so it doesn’t compete with the actual conversation.

Conversation started · June 2, 2026

States

Four states via the data-state attribute. Programmatic: el._lbBubble.setState('streaming'). Emits lb-bubble-state-change with { state, prev }.

Streaming

Blinking caret at the end of the body. The push streaming API — bubble.appendText(token) — auto-transitions the bubble into this state. Respects prefers-reduced-motion (caret stays dim, no blink).

AI
Assistant just now

Error

Danger-tinted border on the body. Used when the model errors or the stream drops. The consumer drives the state change in response to their own error handler.

AI
Assistant 2:36 PM
The connection to the model dropped. Please retry.

Edited

Appends “(edited)” to the timestamp. Edits always create a branch in the data model — the prev/next arrow affordances ship in Slice 3 (message actions row).

JS
You 2:33 PM
Why is the sky blue, and does it look different from space?

Public API

Consumers reach the instance at el._lbBubble. All methods are stable.

// Read
bubble.getRole();                // 'user' | 'assistant' | 'system'
bubble.getState();               // 'done' | 'streaming' | 'error' | 'edited'

// Write — state transition emits lb-bubble-state-change
bubble.setState('streaming');

// Streaming push API — consumer drives it (no LLM SDK bundled)
bubble.appendText('Token by ');
bubble.appendText('token, ');
bubble.appendText('appended.');
// Auto-transitions to 'streaming' on the first call.
// Consumer calls bubble.setState('done') when the stream ends.

// Escape hatch for consumer-rendered HTML (full markdown engine,
// math, mermaid diagrams, etc.). Per Q1 alignment, letbe-ds ships
// minimal regex-based markdown for plain bodies; setBodyHtml is
// the consumer override.
bubble.setBodyHtml('<p>Rendered by <strong>marked</strong>.</p>');

// Replace body with plain text
bubble.setBodyText('Reset.');

Loading the module

Chat lives in a separate module under js/components/lb-chat.js, following the JS modularization pattern from roadmap item J. Include after lb.js:

<script src="../js/lb.js"></script>
<script src="../js/components/lb-chat.js"></script>

Pages that don’t use Chat skip the second line and get a smaller JS payload.


Message actions & branching

When Thread creates a bubble programmatically via appendBubble({...}), role-appropriate actions auto-inject into the footer: assistant gets Copy / Like / Dislike / Regenerate; user gets Edit / Copy / More. Action clicks emit lb-bubble-action {action, bubble} for the consumer to wire model-side concerns. The Edit action additionally creates an inline editor inside the bubble; saving emits lb-bubble-edit-save {oldValue, newValue} AND creates a sibling branch in the data model.

Every edit creates a branch. The original message is preserved; the new sibling becomes the active path. Bubbles after the inactive sibling auto-hide. Prev/next arrows on the edited bubble flip between siblings; switching restores the descendant chain of whichever sibling is active.

Override the auto-injected actions by passing footerHtml: '...' to appendBubble. Set footerHtml: '' to suppress the action row entirely.


Composer workbench

The composer is a workbench, not just a textarea and a send button. Five new pieces:

  1. Attach button (leading +) opens a file picker. Drag-and-drop anywhere on the composer card also works — including folders (uses webkitGetAsEntry()). The whole card highlights on drag-over.
  2. Context chips appear above the textarea. Each is removable; clicking the × removes the chip and emits lb-composer-chip-remove. Programmatic API: composer.addChip({label, kind, data}) / removeChip(id) / getChips() / clearChips().
  3. Tool toggle chips below the input. Consumer registers tools with composer.setTools([...]) — pass {id, label, icon, active}. Each click toggles aria-pressed and emits lb-composer-tool-toggle. composer.getTools() returns the active IDs.
  4. Model picker right-side dropdown. composer.setModels([...], defaultId); emits lb-composer-model-change.
  5. Slash command palette — type / in an empty composer to open. Filter by typing. ↑/↓ navigates, Enter selects, Esc closes. Consumer registers commands with composer.registerCommand({id, label, hint, run}). Commands either run a callback or just emit lb-composer-command for the consumer to handle.
  6. Mic button (per Q8) emits lb-composer-voice-request. No voice handling shipped — consumer wires speech recognition (web Speech API, whisper, etc.).

The expanded submit event detail now carries everything the consumer needs to call their model: {value, chips, tools, model}.


Tool call

LB.ToolCall — collapsible card for showing one tool invocation by the assistant. Shows both a compact summary and the raw I/O the tool received and returned. Each card has:

  1. Compact header: tool icon · tool name · one-line verb · status pill · chevron. One-click expand/collapse.
  2. Formatted tab: pretty Input / Output sections the consumer fills with HTML (citation chips, file diffs, syntax-highlighted code, whatever).
  3. Raw I/O tab: pre-formatted JSON of rawInput / rawOutput. The "you can debug what the tool actually saw" affordance users keep asking for.
  4. Confirm gate for risky ops (shell, file write, network calls). Pass risky: true in setData — card auto-expands, the gate appears, and the user must Continue or Cancel. Emits lb-tool-call-confirm / lb-tool-call-cancel for the consumer.
  5. Status colours: pending / running (accent) / success (success) / error (danger border + tint) / awaiting-confirm (warning). The card border + background updates with status so failed calls stand out at a glance.

API: setData({icon, name, summary, status, inputHtml, outputHtml, rawInput, rawOutput, risky}) for declarative bulk-state (per Q4 alignment); appendOutputHtml(html) for imperative streaming additions during a running tool call; setStatus(s), expand() / collapse() / toggle(). Mount inside a Bubble’s body, between two bubbles, or in any thread position the consumer wants.

Try it: use the slash palette below — /tool web, /tool code, /tool file, or /tool risky — to inject example tool calls into the thread.

Static examples

Input
Query: “letbe-ds chat component patterns”
Max results: 5
Output
  • letbe-ds documentation — chat primitives
  • Pluggable engine HARD rule reference
  • letbe-ds gallery — AI Chat template
Raw input
{
  "query": "letbe-ds chat component patterns",
  "max_results": 5,
  "engine": "duckduckgo"
}
Raw output
{
  "results": [
    { "url": "https://example.com/letbe-ds-chat", "title": "letbe-ds — chat primitives" },
    { "url": "https://example.com/pluggable-engines", "title": "Pluggable engine HARD rule" }
  ],
  "fetched_at": "2026-06-02T15:42:11Z"
}
Input
import pandas as pd
df = pd.read_csv("uploads/sales.csv")
df.describe()
Output
Streaming…

This will delete /tmp/build-cache recursively. Continue?

Command
rm -rf /tmp/build-cache
Error
EACCES: permission denied, open '/etc/secrets'

Working chat — LB.Thread + LB.Composer

A complete chat surface composed of Thread + Composer. Type a message, hit Send (or ⌘/Ctrl + Enter) to see a fake assistant response stream in. The composer’s send button morphs to a stop button while the assistant streams — click Stop to halt the stream early. Scroll up while a response is streaming to surface the jump-to-bottom pill at the bottom-right of the thread.

Try the branching: after a few exchanges, hover one of your user messages and click the pencil icon. Edit the text and click Save & submit. A sibling branch is created — prev/next arrows appear on the user message, and the assistant streams a fresh response. Flip between branches with the arrows; descendants of the inactive sibling hide automatically.

Try the workbench: drop a file (or folder!) onto the composer card — chip appears above the textarea. Toggle a tool chip (Web / Code / Image) to mark it active. Click the model picker to swap models. Type / at an empty composer to open the slash palette — try /clear to wipe the thread, /system to insert a system note, or /mock to stream a fake assistant reply with no user message.

This is what arrives at end-of-Slice-2: a working chat that any consumer can wire to their own model API by listening for lb-composer-submit and calling thread._lbThread.appendBubble({...}) plus bubble._lbBubble.appendText(token) as their stream arrives.

Conversation started · June 2, 2026
AI
Assistant just now
Hi! Type a message below to start. This demo uses a mock stream — the actual model integration is consumer-supplied.

Thread + Composer API

Below is the actual wiring code from the working demo above. About 30 lines of glue. A real consumer replaces the mockStream() call with their own fetch / EventSource / Anthropic SDK / OpenAI SDK stream.

const thread = document.getElementById('demo-thread');
const composer = document.getElementById('demo-composer');

composer.addEventListener('lb-composer-submit', (e) => {
  // 1. Append the user's message
  thread._lbThread.appendBubble({
    role: 'user',
    sender: 'You',
    timestamp: 'just now',
    avatarHtml: '<span class="lb-avatar lb-avatar--small">YOU</span>',
    body: e.detail.value,
  });
  composer._lbComposer.clear();
  composer._lbComposer.setState('sending');

  // 2. Append a streaming assistant bubble + start streaming
  const reply = thread._lbThread.appendBubble({
    role: 'assistant',
    state: 'streaming',
    sender: 'Assistant',
    timestamp: 'just now',
    avatarHtml: '<span class="lb-avatar lb-avatar--small">AI</span>',
  });
  composer._lbComposer.setState('streaming');

  mockStream(reply._lbBubble, () => {
    composer._lbComposer.setState('idle');
  });
});

composer.addEventListener('lb-composer-stop', () => {
  // Consumer halts their own stream here, then:
  composer._lbComposer.setState('idle');
});

Conversation list — LB.ConvList

Sidebar primitive for chat history. Date-grouped automatically (Today / Yesterday / Previous 7 / 30 Days / month buckets), pinned bucket on top, opt-in search filter, opt-in color tag stripe per conversation, unread badge, kebab popover for per-row actions. Emits lb-conv-select on row activation and lb-conv-action on kebab choice (rename / pin·unpin / share / move / delete) so the consumer wires their own backend.

Select a conversation from the sidebar…

Wiring — the consumer is the source of truth for conversation data, and reacts to events to update its own state:

const listEl = document.getElementById('my-conv-list');
const list   = listEl._lbConvList;

list.setConvs([
  { id: 'c1', title: 'Quick refactor question',  timestamp: Date.now(),                  unread: 2 },
  { id: 'c2', title: 'Onboarding flow review',   timestamp: Date.now() - 36e5,           colorTag: '#8b5cf6' },
  { id: 'c3', title: 'Design system migration',  timestamp: Date.now() - 26e5 * 24,      pinned: true },
  // …
]);
list.setActive('c1');

listEl.addEventListener('lb-conv-select', (e) => {
  loadConversation(e.detail.id);
});

listEl.addEventListener('lb-conv-action', (e) => {
  if (e.detail.action === 'rename') openRenameDialog(e.detail.id);
  else if (e.detail.action === 'delete') confirmDelete(e.detail.id);
  // pin / unpin / share / move handled similarly
});