Skip to content

Surface registration

projects/matrix-3/packages/chat-component/src/surface/register-surface.ts is the single registration entrypoint. This page documents what it does, why it does it that way, and what hosts must (and must not) do.

What the file is

ts
import { ConversationSurfaceActor }  from './ConversationSurfaceActor';
import { ConversationRenderer }      from './ConversationRenderer';
import { ConversationInput }         from './ConversationInput';
import { ConversationSessionList }   from './ConversationSessionList';

export function registerConversationSurfaceElements(): void {
  if (typeof customElements === 'undefined') return;

  const definitions: Array<[string, CustomElementConstructor]> = [
    ['conversation-surface',      ConversationSurfaceActor],
    ['conversation-renderer',     ConversationRenderer],
    ['conversation-input',        ConversationInput],
    ['conversation-session-list', ConversationSessionList],
  ];

  for (const [tag, ctor] of definitions) {
    if (!customElements.get(tag)) {
      customElements.define(tag, ctor);
    }
  }
}

registerConversationSurfaceElements();   // side-effect on module load

if (!customElements.get(tag)) is the idempotency guard. A second import is a no-op.

Why side-effect on import

Hosts can register with a single dynamic import:

ts
await import('@open-matrix/chat-component/surface');
// At this point all four custom elements are registered.

If registration weren't a side-effect, every host would need:

ts
const mod = await import('@open-matrix/chat-component/surface');
mod.registerConversationSurfaceElements();

That's two operations and an opportunity to forget the second. The side-effect convention is consistent with how Chat's own register-elements.ts self-registers (line 64).

Class identity guarantee

The four classes extend MatrixActorHtmlElement from @open-matrix/core. For the lazy-chunk pattern to work, the host must use the same @open-matrix/core instance as the surface. Otherwise instanceof checks fail and the actor system rejects the bus connection.

The Vite resolution rule that protects this:

  • Director's vite.config.ts aliases @open-matrix/core/* to source.
  • chat-component's tsconfig.json references the same @open-matrix/core workspace package.
  • Vite walks both, sees the same source path, and emits a single copy of the core classes in the bundle.

If anything breaks that alignment (dual installation of @open-matrix/core, two Vite builds with different resolution), customElements.define may throw "tag already defined" with a class that has the same shape but different identity. Diagnosing those is painful; the prevention is to keep @open-matrix/core resolution unified.

What a host must do

Minimum:

ts
class MyHost extends MatrixActorHtmlElement {
  async ensureSurface() {
    await import('@open-matrix/chat-component/surface');
    // Now create the element.
    this.shadowRoot!.innerHTML = '<conversation-surface name="conv"></conversation-surface>';
  }
}

Better (matches Director's DirectorChatSurfaceHost):

  • Track loadState (idle | loading | ready | unavailable).
  • Catch import failures and render an unavailable message with rebuild hint.
  • Queue any surface.set-target { mount } ops that arrive before ready.
  • Forward target-set ops via DOM event after registration.

What a host must NOT do

  • Import the surface statically (import '@open-matrix/chat-component/surface'). That would pull the surface into the host's main bundle, defeating the lazy-loading benefit and (more importantly) hard-linking class identity to whatever the host's resolution graph looked like at build time.
  • Re-define the four custom elements with different classes. The first registration wins; trying to register conversation-surface again with a different class throws.
  • Bypass customElements.define and use the class directly. The actor lifecycle (context, mailbox subscription, connectedCallback) requires DOM registration.

Per-element interfaces

ConversationSurfaceActor (<conversation-surface>):

  • Accepts: surface.set-target { mount }, surface.set-session { sessionId }, surface.clear { options? }.
  • Emits: conversation:target-changed { mount }, conversation:session-changed { sessionId }, conversation:error { message }.

ConversationRenderer (<conversation-renderer>):

  • Accepts: renderer.add-message, renderer.set-messages, renderer.stream-thinking, renderer.stream-append, renderer.add-tool, renderer.add-tool-result, renderer.stream-end, renderer.clear.

ConversationInput (<conversation-input>):

  • Accepts: input.enable, input.disable, input.focus, input.set-placeholder.
  • Emits: conversation:submit { text }.

ConversationSessionList (<conversation-session-list>):

  • Accepts: sessions.set-list { sessions }, sessions.set-active { sessionId }.
  • Emits: conversation:session-selected { sessionId }, conversation:session-new, conversation:sessions-refresh-requested.

See also

Source: projects/matrix-3/packages/chat-component/src/surface/register-surface.ts (full file), each Conversation*.ts (accepts/emits).