Skip to content

Chat bubble

<aparte-chat-bubble>

Class: AparteChatBubble

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.

The bubble element is content-visibility: auto for its streaming/scroll performance (measured, not assumed — see bubble.css), and that containment makes the bubble the containing block for position: fixed descendants: a popover mounted inside it (an <aparte-select> from a registerToolRenderer renderer, say) is clipped to the bubble’s own box, not the window. Mount it outside instead — Custom tool renderer shows the pattern.

Width Open in a tab
The real element, in its own document so this site's CSS cannot reach it. It renders the same example printed below — every framework mounts this same element, so the rendering is what you get in all five; the code is what changes.
<!-- 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>
AttributeDescription
data-roleThe 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.)
contentPlain text content, for a bubble with no segments.
timestampEpoch milliseconds OR a date string: _updateTimestamp accepts either and only coerces when the value is numeric.
message-idHow streaming and the action bar address this bubble.
streamingHides the action bar and shows the caret while a reply is in flight.
nameThe display name in the header.
MethodDescription
appendToken(chunk: string): voidAppend a token chunk (for streaming)
setContent(content: string): voidSet content directly
getContent(): stringGet current content
setSegments(segments: AparteSegment[]): voidSet segments for rich content
addSegment(segment: AparteSegment): voidAdd a segment
updateSegment(segmentId: string, updates: Partial&lt;AparteSegment>): voidUpdate a specific segment
appendToSegment(segmentId: string, content: string): voidAppend content to a segment
getSegments(): AparteSegment[]Get all segments
removeSegment(segmentId: string): voidScoped 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[]): voidSet attachments (chips shown above message content, user role only)
setUsage(usage: AparteUsage | null | undefined): voidSet 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): voidUpdate 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&lt;AparteMessage>): voidAtomic update for the message
setTranscriptBusy(busy: boolean): void
EventTypeDescription
aparte-attachment-previewCustomEvent&lt;AparteAttachmentPreviewEventDetail>An attached image was clicked, asking the app to open it full-size.
aparte-actionCustomEvent&lt;AparteActionEventDetail>A custom action-bar button was pressed.
aparte-retryCustomEvent&lt;AparteRetryEventDetail>Retry was pressed; the host forks the turn.
aparte-feedbackCustomEvent&lt;AparteFeedbackEventDetail>Thumbs up or down.
aparte-message-infoCustomEvent&lt;AparteMessageInfoEventDetail>The info affordance was pressed.
aparte-editCustomEvent&lt;AparteEditEventDetail>An edit was saved.
aparte-branch-navigateCustomEvent&lt;AparteBranchNavigateEventDetail>The ‹1/2› picker moved between sibling versions.
aparte-link-clickCustomEvent&lt;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.

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.

VariableDefaultDescription
--aparte-message-gapvar(--aparte-space-6)Gap between the avatar column and the body (the viewport reuses it between messages).
--aparte-message-padding-blockvar(--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-inlinevar(--aparte-space-6)Horizontal padding of one message row.
--aparte-message-max-width800pxWidth of the centred message row.
--aparte-message-content-radiusvar(--aparte-radius-bubble)Radius of the painted content box.
--aparte-message-content-paddingvar(--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-assistanttransparentBackground of the assistant box — transparent on purpose (AI-chat convention, not messaging).
--aparte-message-content-text-uservar(--aparte-text)Text colour inside the user box.
--aparte-message-content-text-assistantvar(--aparte-text)Text colour inside the assistant box.
--aparte-avatar-size32pxSquare size of the avatar slot.
--aparte-avatar-radius-ratio0.28Avatar 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-ratio0.34Size of the initial, as a fraction of the avatar’s size.
--aparte-avatar-bg-uservar(--aparte-primary)Avatar background, user role.
--aparte-avatar-text-uservar(--aparte-text-inverse)Avatar text colour, user role.
--aparte-avatar-bg-assistantvar(--aparte-surface-3)Avatar background, assistant role.
--aparte-avatar-text-assistantvar(--aparte-text)Avatar text colour, assistant role.
--aparte-avatar-image-usernonebackground-image for the user avatar — a logo with no AvatarProvider and no JS.
--aparte-avatar-image-assistantnonebackground-image for the assistant avatar.
--aparte-avatar-image-size90%background-size for both avatar images.
--aparte-name-font-sizevar(--aparte-font-size-md)Sender name in the header.
--aparte-name-colorvar(--aparte-text)Sender name colour.
--aparte-timestamp-font-sizevar(--aparte-font-size-sm)Timestamp in the header.
--aparte-timestamp-colorvar(--aparte-text-muted)Timestamp colour.
--aparte-content-font-sizevar(--aparte-font-size-base)Body type size, applied to both the plain-content and the segments container.
--aparte-content-colorvar(--aparte-text)Body text colour.
--aparte-content-line-heightvar(--aparte-line-height-loose)Body line height.
--aparte-attachments-max-height140pxCap on the sent-attachment strip; past it the strip scrolls instead of growing.
--aparte-attachment-image-size72pxTile size in the strip. The strip re-declares the global 72px down to 40px, since these are thumbnails inside a conversation.
--aparte-thumb-radiusvar(--aparte-radius-lg)Attachment tile radius (shared with the composer’s preview tiles).
--aparte-thumb-name-color#ffffffFilename overlaid on a tile.
--aparte-thumb-name-scrim—Gradient behind that filename, so it stays legible over any image.
--aparte-thumb-name-padding14px 5px 4pxPadding of the filename overlay.
--aparte-action-bar-gapvar(--aparte-space-2)Gap between action buttons (and between the footer’s two regions).
--aparte-action-bar-btn-sizevar(--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-colorvar(--aparte-text-muted)Action icon colour at rest.
--aparte-action-bar-btn-hover-bgvar(--aparte-surface-2)Action button hover background (the branch arrows reuse it).
--aparte-action-bar-btn-hover-colorvar(--aparte-text)Action icon colour on hover.
--aparte-branch-picker-gapvar(--aparte-space-2)Gap between the arrows and the position label.
--aparte-branch-picker-btn-colorvar(--aparte-text-muted)Arrow colour at rest.
--aparte-branch-picker-btn-hover-colorvar(--aparte-text)Arrow colour on hover (a disabled arrow is dimmed instead).
--aparte-branch-picker-label-sizevar(--aparte-font-size-sm)Type size of the position label.
--aparte-branch-picker-label-colorvar(--aparte-text-muted)Colour of the position label.
--aparte-branch-picker-label-min-width32pxReserved label width, so 9 / 9 growing to 10 / 12 does not shift the arrows.
--aparte-waiting-height1.5emMin height of the waiting region, so the first token does not jump the layout.
--aparte-waiting-dot-gapvar(--aparte-space-2)Gap between the three waiting dots.
--aparte-status-dot-size6pxDiameter of a waiting dot (shared with the status indicator).
--aparte-status-colorvar(--aparte-text-muted)Colour of the waiting dots (shared with the status indicator).
--aparte-error-solid#dc2626Ring drawn around the avatar while data-error is set. The error CARD itself belongs to the error segment renderer.

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));

Installation and the framework-specific traps: React · Vue · Svelte · Angular.