Chat viewport
<aparte-chat-viewport>
The transcript surface: a light-DOM container with sticky scrolling, token streaming and segment-aware rendering.
Features:
- Smart Scroll: Sticks to bottom when user is at bottom, stops on manual scroll up
- appendToken(): For simple content streaming
- appendToSegment(): For segment-aware streaming (thinking, code, etc.)
Two DOM modes. By default the element builds its own scroll surface
(.aparte-viewport-container) around a .aparte-messages-wrapper, and creates the
<aparte-chat-bubble> elements itself (the last max-rendered-bubbles of the active
path). With framework-managed set it builds neither wrapper: the HOST is the scroll
surface, the framework owns the bubble elements, and the bottom spacer becomes additive
host padding instead of an element — a relocated or removed child is what desynchronises
a framework’s view tree from the live DOM, so this mode touches neither. The one child
it appends in both modes is the scroll-to-bottom button, kept trailing.
Children you write inside the element are just children: there is no shadow root and no
slot to target. In the default mode they are MOVED into the internal
.aparte-messages-wrapper at first render, ahead of the bottom spacer, so pre-rendered
<aparte-chat-bubble> elements land in the transcript flow. A custom element of your own
is relocated the same way, and if it carries data-aparte-bubble plus a matching
message-id it also receives the live token and segment pushes, not just a restyle.
Do not expect such a child to outlive the transcript, though: anything that re-renders the
active path (addBranch, addSiblingOf, navigateBranch, importTree) empties the
wrapper and rebuilds it from the repository, so only what the repository holds comes back —
and clearAll() removes <aparte-chat-bubble> nodes only, so a [data-aparte-bubble]
element of your own is left behind with nothing left to render. With framework-managed
set children are not relocated: they stay direct children of the host, which is itself the
scroll surface.
Messages are held as a TREE (siblings, branches, an active path), which is what lets a retry fork and a bubble’s sibling picker navigate with no host object involved.
What it is NOT is storage. max-rendered-bubbles is a DOM ceiling and never evicts
from the repository — the full tree and its snapshot stay complete, exportTree() /
importTree() hand that snapshot to whoever owns persistence, and real history
retention is configured on the conversation manager instead. It is not a chat either:
a bare viewport IS a valid AparteClient target, but the composer, the transport and
the shell layout are other elements.
Example
Section titled “Example”<!-- On its own, outside `<aparte-chat>`. Give it a height: it fills what it is given and owns the scrolling inside that box, so a viewport in an auto-height parent grows forever instead of scrolling. Messages are pushed in — it fetches nothing. --><aparte-chat-viewport style="height: 24rem"></aparte-chat-viewport>
<script> const viewport = document.querySelector('aparte-chat-viewport'); viewport.appendMessage({ id: 'u1', role: 'user', content: 'What is a transport?', timestamp: Date.now() }); viewport.appendMessage({ id: 'a1', role: 'assistant', content: 'The object that talks to the model. Swap it and the UI does not change.', timestamp: Date.now(), });</script>// Three calls are a whole streamed turn.const viewport = document.querySelector('aparte-chat-viewport')!;
viewport.appendMessage({ id: 'a1', role: 'assistant', content: '', timestamp: Date.now() });for await (const chunk of tokens) viewport.appendToken('a1', chunk);viewport.completeMessage('a1'); // stops the streaming caretAttributes
Section titled “Attributes”| Attribute | Description |
|---|---|
scroll-threshold | How close to the bottom still counts as “at the bottom”. |
max-rendered-bubbles | Caps how many bubbles stay in the DOM; older ones are released. |
framework-managed | The wrapper’s explicit hands-off signal: set it and this element builds no wrapper of its own and relocates none of the nodes the FRAMEWORK renders into it, because the framework owns them. Not “none of its children”: core’s own scroll-to-bottom button is re-appended whenever it stops being last, and that path runs in this mode only. All four wrappers set it. |
data-busy | Reflected BY the element while a turn streams: the transcript is read-only meanwhile, and every bubble inside reads it (at connect, and when it changes). The vanilla path derives it from the repository; a framework host sets it through setTranscriptBusy(). Read-only from the outside. |
Methods
Section titled “Methods”| Method | Description |
|---|---|
configure(config: AparteViewportConfig): void | Configure viewport with options |
appendToken(messageId: string, chunk: string): void | Append a token chunk to a message’s content (simple text streaming) |
appendToSegment(messageId: string, segmentId: string, chunk: string): void | Append content to a specific segment within a message |
setTranscriptBusy(busy: boolean): void | The transcript’s read-only-while-streaming flag — data-busy on this element and fanned out to the bubbles it holds. The vanilla path derives it from the repository (_syncBusy); a framework host, whose messages live outside the repository during a turn, writes it directly from its own streaming id. |
addSegment(segment: AparteSegment): void | Add a new segment. Two calling conventions are accepted: - addSegment(segment) — AparteClient’s 1-arg “operate on the current (head) message” convention (also what a wrapper host installs); - addSegment(messageId, segment) — explicit standalone form. The first argument’s type disambiguates (string = messageId, object = segment), so a raw viewport driven by AparteClient no longer drops text (the args used to bind one position short, creating a phantom message). |
addSegment(messageId: string, segment: AparteSegment): void | Add a new segment. Two calling conventions are accepted: - addSegment(segment) — AparteClient’s 1-arg “operate on the current (head) message” convention (also what a wrapper host installs); - addSegment(messageId, segment) — explicit standalone form. The first argument’s type disambiguates (string = messageId, object = segment), so a raw viewport driven by AparteClient no longer drops text (the args used to bind one position short, creating a phantom message). |
updateSegment(segmentId: string, updates: Partial<AparteSegment>): void | Update a segment. updateSegment(segmentId, updates) (1-arg client convention → current message) or updateSegment(messageId, segmentId, updates) (explicit). Disambiguated by arity: the 3rd arg is absent and the 2nd is the updates object in the 1-arg form. |
updateSegment(messageId: string, segmentId: string, updates: Partial<AparteSegment>): void | Update a segment. updateSegment(segmentId, updates) (1-arg client convention → current message) or updateSegment(messageId, segmentId, updates) (explicit). Disambiguated by arity: the 3rd arg is absent and the 2nd is the updates object in the 1-arg form. |
removeSegment(segmentId: string): void | Remove a segment. removeSegment(segmentId) (1-arg client convention → current message) or removeSegment(messageId, segmentId) (explicit). |
removeSegment(messageId: string, segmentId: string): void | Remove a segment. removeSegment(segmentId) (1-arg client convention → current message) or removeSegment(messageId, segmentId) (explicit). |
startSegment(messageId: string, segment: AparteSegment): void | Start a new streaming segment (e.g., thinking or code block) Creates the segment and marks it as streaming |
completeSegment(messageId: string, segmentId: string): void | Complete a streaming segment |
setUsage(messageId: string, usage: AparteUsage): void | Persist token usage on a message and propagate to the live bubble, which is what allows the info (“i”) action to render — provided the app declared it with aparteGlobalConfig.setBubbleActions({ info: true }); it is off by default, since the popover it opens belongs to the app. |
completeMessage(messageId: string): void | Mark a message as finished streaming |
updateMessage(messageId: string, updates: Partial<AparteMessage>): void | Atomic update for a message by ID Supports updating content, status, segments, and other metadata |
addMessage(message: AparteMessage): void | Add a complete message to the message registry. |
appendMessage(message: AparteMessage, options?: { historical?: boolean }): void | Append a new message and create its bubble in the DOM. Implements the same contract as the Angular wrapper’s appendMessage(), making aparte-chat-viewport a fully standalone target for aparte-client. When _frameworkManagedDOM is true, only the internal repo is updated — the framework owns the DOM and will create the bubble element itself. |
updateLastMessage(content: string, options?: { append?: boolean }): void | Update the last message content, optionally appending. Implements the same contract as the Angular wrapper’s updateLastMessage(),’ making aparte-chat-viewport a fully standalone streaming target for aparte-client. |
addBranch(messageId: string): void | Add a new sibling branch to an assistant message (retry flow). Creates a new empty assistant message as a sibling of messageId under the same parent, switches the active branch to it, and re-renders the active path. |
addSiblingOf(existingId: string, newMessage: AparteMessage): string | null | Add a new message relative to existingId, switch to it, and re-render. Role-aware semantics: - existingId is an assistant message → create a sibling (same parent), so the active path replaces the old response with the new one. - existingId is a user message → create a child of that message, so the user message stays on the active path and the new response follows it. Returns the new message’s ID, or null if existingId is not found. |
navigateBranch(messageId: string, direction: 'prev' | 'next'): void | Navigate to the previous or next sibling branch of a message. Triggers a full re-render of the active path. |
truncateResponsesAfter(userMessageId: string): void | Remove ALL responses to a user message (every child branch) and set head back to userMessageId. Cleaner than truncateFrom for edit flows: it discards stale sibling branches so the regenerated response starts alone. |
truncateFrom(messageId: string): void | Remove all messages from messageId onwards (inclusive) from state and DOM. Used by edit to truncate history before re-generating. |
getMessage(messageId: string): AparteMessage | undefined | Get a message by ID |
getMessages(): AparteMessage[] | The messages on the currently ACTIVE path, root → head — not the whole tree. A message that was retried contributes only the branch currently selected; exportTree() is what returns every sibling. |
exportTree(): ExportedMessageRepository | Export the full conversation tree (all branches, not just the active path). The returned snapshot can be persisted and restored via importTree(). |
importTree(tree: ExportedMessageRepository): void | Import a previously-exported tree snapshot, restoring the full branch topology and the active head. Replaces any existing repo content. Always calls _reRenderActivePath(): - In native DOM mode: rebuilds bubble elements. - In framework-managed mode: skips DOM manipulation but dispatches aparte-path-changed with sibling metadata so the wrapper can update branch arrows on already-rendered bubbles. |
clearAll(options?: { revokeAttachments?: boolean }): void | Clear all messages and remove all bubble elements from the DOM. Also dispatches a aparte-reset-done event. In framework-managed mode the DOM is owned by the host framework (Angular |
clearMessages(): void | Clear all messages |
setMessages(messages: AparteMessage[]): void | Replace the entire message list in one shot. Used when switching conversations: clears existing repo + DOM, then appends each message. In framework-managed mode the framework re-renders the bubble DOM itself; we only update the internal repo (used by aparte-client to build chat history). |
scrollToBottom(): void | Scroll to bottom of viewport |
resetSpacer(): void | Reset the bottom spacer to 0 height immediately and freeze it for 350 ms so the host-app layout transition (e.g. flex: 0→1 animation) does not trigger a premature recalculation with mid-animation geometry. Call before a full messages swap. |
setAutoScroll(enabled: boolean): void | Enable or disable auto-scroll |
setFrameworkManagedDOM(managed: boolean): void | Signal that a framework (e.g. Angular) manages the bubble DOM. When true, branch navigation dispatches aparte-path-changed without clearing/rebuilding the messages wrapper — the framework re-renders instead. |
requestSmoothScroll(): void | Request that the next auto-scroll triggered by a DOM mutation uses smooth behaviour instead of instant. Call this just before adding a user message bubble so the viewport animates down rather than jumping. Resets automatically after the first auto-scroll fires. |
Events
Section titled “Events”| Event | Type | Description |
|---|---|---|
aparte-segment-update | CustomEvent<AparteSegmentUpdateEventDetail> | A segment grew or settled during a stream. |
aparte-reset-done | CustomEvent | clearAll() finished emptying the transcript. No detail. |
aparte-path-changed | CustomEvent<ApartePathChangedEventDetail> | The active branch path changed, after a retry fork or a navigation. |
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-viewport>
Section titled “<aparte-chat-viewport>”| Variable | Default | Description |
|---|---|---|
--aparte-viewport-padding | var(--aparte-space-8) | Padding around the transcript — on .aparte-messages-wrapper, or on the host itself in framework-managed mode, where the auto-scroll spacer is added on top of it. A container narrower than 520px tightens it in the default mode only: that rule reassigns the variable on .aparte-messages-wrapper, which framework-managed mode never builds. |
--aparte-message-gap | var(--aparte-space-6) | Gap between consecutive bubbles in the transcript column (both DOM modes). Shared: it is also the avatar-to-content gap inside a bubble. |
--aparte-scrollbar-thumb | var(--aparte-neutral) | Colour of the transcript’s scrollbar thumb. A host page with a scrollbar of its own sets this and the track so the chat’s does not read as a second, foreign scrollbar. |
--aparte-scrollbar-track | transparent | Colour of the transcript’s scrollbar track. |
--aparte-scrollbar-width | 6px | Width of the WebKit scrollbar on the scroll surface. Firefox and the standard property use scrollbar-width: thin and ignore it. |
--aparte-transcript-inset | var(--aparte-viewport-padding) | Written BY the viewport on the chat host: how far from the host’s inline edge its rows start (padding plus the scrollbar gutter, at the current container step). The composer pads by it, so the two boxes share one edge at every width. Read-only from the outside. |
--aparte-bottom-inset | 0px | How much of the transcript’s bottom is covered by content floating over it. Written by the viewport itself under [overlay-composer] (never set it there — it would be overwritten); a host that overlays a composer of its own, without the attribute, sets it by hand and the spacer, the container padding and the scroll button all clear it. |
--aparte-scroll-btn-size | var(--aparte-btn-size-lg) | Diameter of the scroll-to-bottom button. A coarse pointer raises it to --aparte-touch-target-size. |
--aparte-scroll-btn-shadow | 0 2px 8px rgba(0, 0, 0, 0.12) | Its shadow; the dark theme sets a heavier one. |
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-viewport framework-managed=""></aparte-chat-viewport>el.addEventListener('aparte-segment-update', (e) => use(e.detail));<aparte-chat-viewport framework-managed=""></aparte-chat-viewport>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 framework-managed={false} would render framework-managed="false", which hasAttribute reads as on. Events reach you by ref, typed through the DOM.
<template> <aparte-chat-viewport framework-managed="" @aparte-segment-update="(e) => use(e.detail)" ></aparte-chat-viewport></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-viewport framework-managed="" on:aparte-segment-update={(e) => use(e.detail)}></aparte-chat-viewport>Declared through SvelteHTMLElements, so svelte-check covers the attributes and the on: handlers. A presence attribute takes '', never false.
import { AparteChatViewportDirective } from '@aparte/angular';<aparte-chat-viewport [frameworkManaged]="true" (segmentUpdate)="use($event)"></aparte-chat-viewport>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.