Skip to content

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.

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.
<!-- 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 caret
AttributeDescription
scroll-thresholdHow close to the bottom still counts as “at the bottom”.
max-rendered-bubblesCaps how many bubbles stay in the DOM; older ones are released.
framework-managedThe 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-busyReflected 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.
MethodDescription
configure(config: AparteViewportConfig): voidConfigure viewport with options
appendToken(messageId: string, chunk: string): voidAppend a token chunk to a message’s content (simple text streaming)
appendToSegment(messageId: string, segmentId: string, chunk: string): voidAppend content to a specific segment within a message
setTranscriptBusy(busy: boolean): voidThe 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): voidAdd 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): voidAdd 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&lt;AparteSegment>): voidUpdate 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&lt;AparteSegment>): voidUpdate 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): voidRemove a segment. removeSegment(segmentId) (1-arg client convention → current message) or removeSegment(messageId, segmentId) (explicit).
removeSegment(messageId: string, segmentId: string): voidRemove a segment. removeSegment(segmentId) (1-arg client convention → current message) or removeSegment(messageId, segmentId) (explicit).
startSegment(messageId: string, segment: AparteSegment): voidStart a new streaming segment (e.g., thinking or code block) Creates the segment and marks it as streaming
completeSegment(messageId: string, segmentId: string): voidComplete a streaming segment
setUsage(messageId: string, usage: AparteUsage): voidPersist 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): voidMark a message as finished streaming
updateMessage(messageId: string, updates: Partial&lt;AparteMessage>): voidAtomic update for a message by ID Supports updating content, status, segments, and other metadata
addMessage(message: AparteMessage): voidAdd a complete message to the message registry.
appendMessage(message: AparteMessage, options?: &#123; historical?: boolean &#125;): voidAppend 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?: &#123; append?: boolean &#125;): voidUpdate 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): voidAdd 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 | nullAdd 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'): voidNavigate to the previous or next sibling branch of a message. Triggers a full re-render of the active path.
truncateResponsesAfter(userMessageId: string): voidRemove 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): voidRemove all messages from messageId onwards (inclusive) from state and DOM. Used by edit to truncate history before re-generating.
getMessage(messageId: string): AparteMessage | undefinedGet 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(): ExportedMessageRepositoryExport the full conversation tree (all branches, not just the active path). The returned snapshot can be persisted and restored via importTree().
importTree(tree: ExportedMessageRepository): voidImport 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?: &#123; revokeAttachments?: boolean &#125;): voidClear 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(): voidClear all messages
setMessages(messages: AparteMessage[]): voidReplace 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(): voidScroll to bottom of viewport
resetSpacer(): voidReset 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): voidEnable or disable auto-scroll
setFrameworkManagedDOM(managed: boolean): voidSignal 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(): voidRequest 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.
EventTypeDescription
aparte-segment-updateCustomEvent&lt;AparteSegmentUpdateEventDetail>A segment grew or settled during a stream.
aparte-reset-doneCustomEventclearAll() finished emptying the transcript. No detail.
aparte-path-changedCustomEvent&lt;ApartePathChangedEventDetail>The active branch path changed, after a retry fork or a navigation.

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-viewport-paddingvar(--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-gapvar(--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-thumbvar(--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-tracktransparentColour of the transcript’s scrollbar track.
--aparte-scrollbar-width6pxWidth of the WebKit scrollbar on the scroll surface. Firefox and the standard property use scrollbar-width: thin and ignore it.
--aparte-transcript-insetvar(--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-inset0pxHow 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-sizevar(--aparte-btn-size-lg)Diameter of the scroll-to-bottom button. A coarse pointer raises it to --aparte-touch-target-size.
--aparte-scroll-btn-shadow0 2px 8px rgba(0, 0, 0, 0.12)Its shadow; the dark theme sets a heavier one.

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

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