Chat bubble
<aparte-chat-bubble>
One message: plain content or a list of rich segments, in light DOM.
Normally created for you by <aparte-chat-viewport>, one per message in the store;
you write the tag by hand only when you drive the DOM yourself. It is ONE message
with one role — a transcript is the viewport’s job, and a bubble is not a
general-purpose card.
Not a slot host. _render() writes its own markup into the light DOM on
connect, so children placed inside the tag are replaced rather than projected.
Everything customizable is a registered hook instead of a child: the structural
shell (setBubbleShellRenderer — it must root at .aparte-message and carry the
region hooks, since every query here is null-guarded and a partial shell silently
loses that region), the avatar (setAvatarProvider), the attachment chips
(setAttachmentRenderer), the ‹1/2› position indicator
(setSiblingNavRenderer) and the body itself (registerSegmentRenderer).
Two content paths, mutually exclusive: the content attribute (plain text run
through the configured Markdown provider, then highlighted once — after streaming
ends, not per token) and setSegments() / addSegment(). Segments win:
.aparte-content stays hidden for as long as any exist. The painted
.aparte-message-content box hides itself when there is nothing in it, so a
message that is only attachments is not a coloured rectangle.
The bubble owns no transport and no host behaviour. The action bar and the branch
picker only dispatch the events below; nothing here retries a turn, persists an
edit, opens a stats popover or switches a branch. Which buttons exist follows from
that: copy is on by default, edit / retry / feedback need
setBubbleActions, info needs both that flag and a prior setUsage() (a details
button over no numbers is a dead button), and an image attachment becomes a preview
button only once setHostHandlers declares a lightbox — undeclared it stays a
picture, with no role, tab stop or pointer.
The error state is derived from the segments (an error segment sets data-error
on .aparte-message), never from a status attribute, so it behaves identically in
vanilla and in every wrapper.
All seven events are declared by hand rather than left to the analyser, which
found six. aparte-branch-navigate is dispatched from the _onBranchPickerClick
arrow class field, and the auto-detection visits ts.isMethodDeclaration only —
so the one event belonging to the branch picker was the one missing from the
manifest, and from the generated reference, for as long as both existed.
Example
Section titled “Example”<!-- Rendered for you by the viewport. Written by hand only when you drive the DOM yourself: `message-id` is what streaming and the action bar address it by. --><aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant" content="Hello."></aparte-chat-bubble>
<!-- While a reply is in flight: `streaming` hides the action bar and shows the caret. --><aparte-chat-bubble message-id="a2" data-role="assistant" streaming></aparte-chat-bubble>
<!-- One reply among several. `setSiblings(count, index)` is what draws the picker, and it is a METHOD, not an attribute — so a branch cannot be shown by markup alone. Retry forks a sibling instead of overwriting the reply, and this is the control that walks them; each press dispatches `aparte-branch-navigate` for a host to answer. Kept in the example because a guide that describes branching has no other way to SHOW it. --><aparte-chat-bubble message-id="a3" data-role="assistant" name="Assistant" content="A second take on the same question."></aparte-chat-bubble>
<script> document.querySelector('aparte-chat-bubble[message-id="a3"]').setSiblings(2, 0);</script>Attributes
Section titled “Attributes”| Attribute | Description |
|---|---|
data-role | The message role, user or assistant — the one channel for it, and what the CSS keys off. (role is ARIA’s attribute; this element sets it to article itself and no longer reads a message role from it.) |
content | Plain text content, for a bubble with no segments. |
timestamp | Epoch milliseconds OR a date string: _updateTimestamp accepts either and only coerces when the value is numeric. |
message-id | How streaming and the action bar address this bubble. |
streaming | Hides the action bar and shows the caret while a reply is in flight. |
name | The display name in the header. |
Methods
Section titled “Methods”| Method | Description |
|---|---|
appendToken(chunk: string): void | Append a token chunk (for streaming) |
setContent(content: string): void | Set content directly |
getContent(): string | Get current content |
setSegments(segments: AparteSegment[]): void | Set segments for rich content |
addSegment(segment: AparteSegment): void | Add a segment |
updateSegment(segmentId: string, updates: Partial<AparteSegment>): void | Update a specific segment |
appendToSegment(segmentId: string, content: string): void | Append content to a segment |
getSegments(): AparteSegment[] | Get all segments |
removeSegment(segmentId: string): void | Scoped to DIRECT children on purpose. Segments are appended as direct children of the container, but a descendant query returns the first match in document order — and sanitized model markdown renders inside that same container, with data-* attributes deliberately preserved (they are inert). So a decoy data-segment-id planted in an earlier segment’s prose used to win over the real segment element. Parser ids are unguessable UUIDs, but a tool segment is tool-${toolCallId} and the MODEL chooses that id — so this was reachable, and pointing an update at a decoy left a rejected tool rendering as still-running: a spoof against the human-in-the-loop control. |
setAttachments(attachments: AparteAttachment[]): void | Set attachments (chips shown above message content, user role only) |
setUsage(usage: AparteUsage | null | undefined): void | Set token usage + timing for this message (assistant only). This is the precondition for the info (“i”) action, not the trigger: the button appears only if the app also declared it wants it — aparteGlobalConfig.setBubbleActions({ info: true }) — because the stats popover it opens (aparte-message-info) is the app’s, and core has none. Without usage there is nothing to show, so the button never renders either way. |
setSiblings(count: number, index: number): void | Update the branch picker UI for tree-based navigation. The viewport calls this after a branch switch or re-render. Prev/Next clicks dispatch aparte-branch-navigate (bubbles: true) so the viewport can handle the actual tree switch. |
updateMessage(updates: Partial<AparteMessage>): void | Atomic update for the message |
setTranscriptBusy(busy: boolean): void |
Events
Section titled “Events”| Event | Type | Description |
|---|---|---|
aparte-attachment-preview | CustomEvent<AparteAttachmentPreviewEventDetail> | An attached image was clicked, asking the app to open it full-size. |
aparte-action | CustomEvent<AparteActionEventDetail> | A custom action-bar button was pressed. |
aparte-retry | CustomEvent<AparteRetryEventDetail> | Retry was pressed; the host forks the turn. |
aparte-feedback | CustomEvent<AparteFeedbackEventDetail> | Thumbs up or down. |
aparte-message-info | CustomEvent<AparteMessageInfoEventDetail> | The info affordance was pressed. |
aparte-edit | CustomEvent<AparteEditEventDetail> | An edit was saved. |
aparte-branch-navigate | CustomEvent<AparteBranchNavigateEventDetail> | The ‹1/2› picker moved between sibling versions. |
aparte-link-click | CustomEvent<AparteLinkClickEventDetail> | A link in the message body is about to be followed. Cancelable: preventDefault() keeps the browser from navigating, so a host can route the link itself. |
Theming
Section titled “Theming”Override any of these on :root, on a subtree, or on one instance — custom properties inherit downward. Some are this element’s own; others are site-wide tokens that also style it, and overriding one of those at :root moves everything that reads it. The full set is in the CSS variables reference.
<aparte-chat-bubble>
Section titled “<aparte-chat-bubble>”| Variable | Default | Description |
|---|---|---|
--aparte-message-gap | var(--aparte-space-6) | Gap between the avatar column and the body (the viewport reuses it between messages). |
--aparte-message-padding-block | var(--aparte-space-8) | Vertical padding of one message row. (The composite --aparte-message-padding is gone: joined on :root, it froze both parts, and the narrow-container override of them never reached the row.) |
--aparte-message-padding-inline | var(--aparte-space-6) | Horizontal padding of one message row. |
--aparte-message-max-width | 800px | Width of the centred message row. |
--aparte-message-content-radius | var(--aparte-radius-bubble) | Radius of the painted content box. |
--aparte-message-content-padding | var(--aparte-space-5) var(--aparte-space-7) | Padding of the USER box only; the assistant’s content is plain full-width prose. |
--aparte-message-content-bg-user | — | Background of the user box: a wash of --aparte-primary over --aparte-surface-1, derived in theme.css so a rebrand moves it (declare it to override). |
--aparte-message-content-bg-assistant | transparent | Background of the assistant box — transparent on purpose (AI-chat convention, not messaging). |
--aparte-message-content-text-user | var(--aparte-text) | Text colour inside the user box. |
--aparte-message-content-text-assistant | var(--aparte-text) | Text colour inside the assistant box. |
--aparte-avatar-size | 32px | Square size of the avatar slot. |
--aparte-avatar-radius-ratio | 0.28 | Avatar corner radius, as a fraction of its size (the corner follows the ramp; 0.28 × 32px is the 9px it used to be). |
--aparte-avatar-initials-ratio | 0.34 | Size of the initial, as a fraction of the avatar’s size. |
--aparte-avatar-bg-user | var(--aparte-primary) | Avatar background, user role. |
--aparte-avatar-text-user | var(--aparte-text-inverse) | Avatar text colour, user role. |
--aparte-avatar-bg-assistant | var(--aparte-surface-3) | Avatar background, assistant role. |
--aparte-avatar-text-assistant | var(--aparte-text) | Avatar text colour, assistant role. |
--aparte-avatar-image-user | none | background-image for the user avatar — a logo with no AvatarProvider and no JS. |
--aparte-avatar-image-assistant | none | background-image for the assistant avatar. |
--aparte-avatar-image-size | 90% | background-size for both avatar images. |
--aparte-name-font-size | var(--aparte-font-size-md) | Sender name in the header. |
--aparte-name-color | var(--aparte-text) | Sender name colour. |
--aparte-timestamp-font-size | var(--aparte-font-size-sm) | Timestamp in the header. |
--aparte-timestamp-color | var(--aparte-text-muted) | Timestamp colour. |
--aparte-content-font-size | var(--aparte-font-size-base) | Body type size, applied to both the plain-content and the segments container. |
--aparte-content-color | var(--aparte-text) | Body text colour. |
--aparte-content-line-height | var(--aparte-line-height-loose) | Body line height. |
--aparte-attachments-max-height | 140px | Cap on the sent-attachment strip; past it the strip scrolls instead of growing. |
--aparte-attachment-image-size | 72px | Tile size in the strip. The strip re-declares the global 72px down to 40px, since these are thumbnails inside a conversation. |
--aparte-thumb-radius | var(--aparte-radius-lg) | Attachment tile radius (shared with the composer’s preview tiles). |
--aparte-thumb-name-color | #ffffff | Filename overlaid on a tile. |
--aparte-thumb-name-scrim | — | Gradient behind that filename, so it stays legible over any image. |
--aparte-thumb-name-padding | 14px 5px 4px | Padding of the filename overlay. |
--aparte-action-bar-gap | var(--aparte-space-2) | Gap between action buttons (and between the footer’s two regions). |
--aparte-action-bar-btn-size | var(--aparte-btn-size-sm) | Square size of an action button (the recipe’s small step); also the footer’s reserved height. |
--aparte-action-bar-btn-color | var(--aparte-text-muted) | Action icon colour at rest. |
--aparte-action-bar-btn-hover-bg | var(--aparte-surface-2) | Action button hover background (the branch arrows reuse it). |
--aparte-action-bar-btn-hover-color | var(--aparte-text) | Action icon colour on hover. |
--aparte-branch-picker-gap | var(--aparte-space-2) | Gap between the arrows and the position label. |
--aparte-branch-picker-btn-color | var(--aparte-text-muted) | Arrow colour at rest. |
--aparte-branch-picker-btn-hover-color | var(--aparte-text) | Arrow colour on hover (a disabled arrow is dimmed instead). |
--aparte-branch-picker-label-size | var(--aparte-font-size-sm) | Type size of the position label. |
--aparte-branch-picker-label-color | var(--aparte-text-muted) | Colour of the position label. |
--aparte-branch-picker-label-min-width | 32px | Reserved label width, so 9 / 9 growing to 10 / 12 does not shift the arrows. |
--aparte-waiting-height | 1.5em | Min height of the waiting region, so the first token does not jump the layout. |
--aparte-waiting-dot-gap | var(--aparte-space-2) | Gap between the three waiting dots. |
--aparte-status-dot-size | 6px | Diameter of a waiting dot (shared with the status indicator). |
--aparte-status-color | var(--aparte-text-muted) | Colour of the waiting dots (shared with the status indicator). |
--aparte-error-solid | #dc2626 | Ring drawn around the avatar while data-error is set. The error CARD itself belongs to the error segment renderer. |
In a framework
Section titled “In a framework”The element is the same object everywhere — the tag does not change. What changes is how an attribute is written and how an event reaches you.
<aparte-chat-bubble streaming=""></aparte-chat-bubble>el.addEventListener('aparte-attachment-preview', (e) => use(e.detail));<aparte-chat-bubble streaming=""></aparte-chat-bubble>The aparte-* tags are typed JSX intrinsics as soon as you import from @aparte/react. A presence attribute takes '', never true — React stringifies it, and streaming={false} would render streaming="false", which hasAttribute reads as on. Events reach you by ref, typed through the DOM.
<template> <aparte-chat-bubble streaming="" @aparte-attachment-preview="(e) => use(e.detail)" ></aparte-chat-bubble></template>Declared through Vue’s GlobalComponents, so vue-tsc checks the tag in any template. A presence attribute takes '' to set and null to remove, never false.
<aparte-chat-bubble streaming="" on:aparte-attachment-preview={(e) => use(e.detail)}></aparte-chat-bubble>Declared through SvelteHTMLElements, so svelte-check covers the attributes and the on: handlers. A presence attribute takes '', never false.
import { AparteChatBubbleDirective } from '@aparte/angular';<aparte-chat-bubble [streaming]="true" (attachmentPreview)="use($event)"></aparte-chat-bubble>A standalone directive whose selector IS the tag, so the real element sits in the template — @if, @for and content projection all reach it — and no CUSTOM_ELEMENTS_SCHEMA is needed.
Installation and the framework-specific traps: React · Vue · Svelte · Angular.