ranui
Version:
A framework-agnostic Web Components UI library built on native custom elements, with TypeScript types, light/dark theming, SSR and PWA support.
185 lines (184 loc) • 8.08 kB
TypeScript
import { EventManager } from '../../utils/builder';
import { RanElement } from '../../utils';
import type { ConversationNode, ConversationNodeDefinition } from 'ranuts/conversation';
/**
* Name of the event the paging affordance fires.
*
* The element does not fetch anything — it does not know where a conversation lives. It
* states that the reader asked, having already captured the anchor; the owner prepends its
* rows and calls {@link Conversation.restoreAnchor}.
*/
export declare const OLDER_REQUEST = "olderrequest";
/**
* One kind of conversation content: the state machine that owns its events, plus how to
* put its state on screen.
*
* The two halves are declared together because a kind that renders nothing and a kind that
* renders are the same thing to the projection — only `mount` differs. Omit `mount` for a
* definition that exists so other definitions can read it through `reader.previous`.
*/
export interface ConversationNodeView<Event = unknown, State = unknown> extends ConversationNodeDefinition<Event, State> {
/**
* Builds the row for a newly opened node.
*
* @param node The node, with its initial state.
* @returns The element to insert, or null to render nothing for this node.
*/
mount?(node: ConversationNode<State>): HTMLElement | null;
/**
* Applies a node's latest state to the row `mount` returned.
*
* Named apart from the definition's `update`, which folds state, because this folds
* nothing: it is the one-way write of already-folded state onto an existing element.
* Called on every publication that touched the node, so it should be cheap — this is
* the path a streaming message takes once per frame. Omitting it means the row never
* changes after it is created.
*
* @param element The row previously returned by `mount`.
* @param node The node, with its current state.
*/
patch?(element: HTMLElement, node: ConversationNode<State>): void;
}
/**
* `<r-conversation>` — renders an append-only event log as a conversation.
*
* The element owns three things that are tedious and easy to get wrong, and nothing else:
* projecting events into nodes (`ranuts/conversation`), keeping the view pinned to its
* floor without fighting the reader (`createBottomFollower`), and reconciling rows against
* the node list. What a message, a tool call, or a status line *looks like* is not its
* business — that is a registered view.
*
* ```ts
* const chat = document.querySelector('r-conversation');
* chat.register({
* kind: 'message',
* match: (e) => (e.type === 'start' ? { id: e.id, role: 'start' } : e.type === 'delta' ? { id: e.id, role: 'update' } : null),
* start: () => ({ text: '' }),
* update: (state, e) => ({ text: state.text + e.text }),
* publication: (e) => (e.type === 'delta' ? 'animation-frame' : 'immediate'),
* mount: () => document.createElement('r-markdown'),
* patch: (el, node) => { (el as HTMLElement & { content: string }).content = node.state.text; },
* });
* chat.push({ type: 'start', id: 'm1' });
* ```
*
* Attributes: `follow` (`true` by default — set `follow="false"` to leave the reader in
* control from the start), `empty` (text shown while there are no rows), `sheet`.
* Fires `pinnedchange` with `detail.pinned` whenever bottom-follow is gained or lost, so a
* "jump to latest" affordance can track it.
*/
export declare class Conversation extends RanElement {
_events: EventManager;
_shadowDom: ShadowRoot;
_scroll: HTMLElement;
_list: HTMLElement;
_older: HTMLElement;
_olderButton: HTMLButtonElement;
_footer: HTMLElement;
private _views;
private _engine;
private _unsubscribe;
private _follower;
/** Row element per node key, so a publication patches rather than rebuilds. */
private _rows;
private _emptyRow;
static get observedAttributes(): string[];
constructor();
/**
* Label for the paging affordance above the first row. Empty hides it.
*
* A conversation log only grows, so a client that renders all of it eventually renders
* more than anyone will read. Paging keeps the rendered set bounded without pretending
* the rest is gone: the button is the statement that there is more, and
* {@link Conversation.olderrequest} is the request for it.
*/
get older(): string;
set older(value: string);
/** Whether a page is in flight; the affordance stays visible and goes inert. */
get loadingOlder(): boolean;
set loadingOlder(value: boolean);
/** Whether new content is followed until the reader scrolls away from the floor. */
get follow(): boolean;
set follow(value: boolean);
/** Text shown while the projection has produced no rows. */
get empty(): string;
set empty(value: string);
get sheet(): string;
set sheet(value: string);
/** Whether the view is currently following new content. */
get pinned(): boolean;
/**
* Registers one kind of conversation content.
*
* Every registration must land before the first {@link push}: the projection is built
* once from the registered set, and a definition added afterwards would silently miss
* every event already folded in.
*
* @param view The definition and its renderer.
* @throws When events have already been pushed, or the kind is already registered.
*/
register<Event, State>(view: ConversationNodeView<Event, State>): void;
/**
* Projects one event and renders whatever it changed.
*
* @param event The log event, in the shape the registered views match on.
*/
push<Event>(event: Event): void;
/**
* Drops one row and every row opened after it.
*
* Editing a message, regenerating an answer and branching are the same operation: the
* conversation diverges here, and what follows the divergence is no longer part of it.
*
* @param key The `kind:id` of the first row to drop.
* @returns How many rows were dropped. Zero means no such row is live, which is a
* caller's cue that its own idea of the conversation is stale.
*/
truncate(key: string): number;
/**
* Runs a burst of pushes as one render.
*
* Replaying a stored conversation without this is quadratic: every event publishes and
* every publication walks the whole transcript. Use it for any replay, restore, or bulk
* insert; a live stream does not need it, because one delta changes one row.
*
* @param run The pushes to run.
*/
batch(run: () => void): void;
/** Drops every node and row, keeping the registered views. */
reset(): void;
/** Scrolls to the floor and resumes following, whatever the reader did. */
scrollToBottom(): void;
/**
* Remembers a row's position before older content is prepended, so the reader keeps
* looking at what they were looking at.
*
* @param key The node key to hold still; defaults to the topmost rendered row.
* @returns Whether a row was captured.
*/
captureAnchor(key?: string): boolean;
/**
* Restores the row captured by {@link captureAnchor}.
*
* @returns Whether an anchor was restored.
*/
restoreAnchor(): boolean;
connectedCallback(): void;
disconnectedCallback(): void;
attributeChangedCallback(name: string, old: string, next: string): void;
handlerExternalCss: () => void;
private _ensureEngine;
private _viewFor;
private _renderNodes;
/**
* Asks the owner for the page above the first row, holding the reader's position.
*
* The anchor is captured before the request rather than after it lands: by then the
* prepend has already moved everything, and there is no earlier position left to record.
* The owner restores it with {@link Conversation.restoreAnchor} once its rows are in.
*/
private _requestOlder;
private _syncOlder;
private _syncEmpty;
}
export default Conversation;