Codumentor logo Codumentor

UI Components

UI plugins are TypeScript projects that produce Web Components (Custom Elements) for the Codumentor interface. This guide covers directory structure, the Plugin SDK, widget development, and building.

See also: Backend Hooks, User Gates (per-user blocking modals), Best Practices, Plugin SDK README


Directory Structure

ui/plugins/my_plugin/
├── manifest.json       # Plugin metadata
├── package.json        # NPM dependencies and build script
├── tsconfig.json       # TypeScript configuration
├── index.js            # Built bundle (generated)
└── src/
    ├── index.ts        # Entry point - registers custom elements
    ├── types.ts        # Data interfaces
    ├── styles.ts       # Component CSS
    └── components/     # Component files (optional organization)
        └── my-card.ts

Quick Start

  1. Copy the template:

``bash
cp -r ui/plugins/_template ui/plugins/my_plugin
``

  1. Update manifest.json:

``json
{
"plugin_id": "my_plugin",
"name": "My Plugin",
"version": "1.0.0",
"description": "What this plugin does",
"event_types": ["plugin.my_plugin.completed"],
"ui": {
"bundle": "index.js",
"sdk_version": "1.0",
"custom_elements": ["x-my-plugin-card"],
"placements": ["transcript-card"]
}
}
``

Supported ui.placements values:

  1. Update package.json:

``json
{
"name": "@codumentor/plugin-my-plugin",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "esbuild src/index.ts --bundle --format=esm --outfile=index.js",
"build:watch": "esbuild src/index.ts --bundle --format=esm --outfile=index.js --watch",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@codumentor/plugin-sdk": "file:../_sdk",
"esbuild": "^0.20.0",
"typescript": "^5.3.0"
}
}
``

  1. Create your component in src/index.ts:

```typescript
import {
BasePluginElement,
bindExpandClick,
getBaseStyles,
escapeHtml,
formatMs,
} from "@codumentor/plugin-sdk";

interface MyPluginData {
message: string;
duration_ms?: number;
}

class MyPluginCard extends BasePluginElement {
render(): void {
const { message, duration_ms } = this._data;

this._shadowRoot.innerHTML =
<style>${getBaseStyles()}</style>
<div class="card ${this._status}">
<div class="header">
<span class="icon">${this.getStatusIcon()}</span>
<span class="title">My Plugin</span>
${duration_ms ?
${formatMs(duration_ms)} : ""}
</div>
<p>${escapeHtml(message)}</p>
</div>
;
}
}

// Register custom element - tag MUST match manifest.json
if (!customElements.get("x-my-plugin-card")) {
customElements.define("x-my-plugin-card", MyPluginCard);
}

export { MyPluginCard };
```

  1. Build:

``bash
cd ui && npm run build:plugins
``


Plugin SDK Reference

The SDK (@codumentor/plugin-sdk) provides types, utilities, and base classes.

Types

import type {
  PluginEvent,        // Full event object passed to components
  PluginEventStatus,  // "pending" | "success" | "error"
  PluginElement,      // Interface for custom elements
  PluginManifest,     // manifest.json structure
} from "@codumentor/plugin-sdk";

Base Class

BasePluginElement<TData> provides:

Property/MethodDescription
_shadowRootShadow DOM root
_eventFull event object
_dataEvent data (typed as TData)
_statusCurrent status
_expandedExpansion state for collapsible cards
escapeHtml(text)HTML escape utility
getStatusIcon()Status emoji
toggleExpanded()Toggle expansion state
render()Abstract — you must implement this

The host sets the event property, which triggers render() automatically.

Utilities

import {
  escapeHtml,    // Escape HTML special characters
  formatMs,      // Format milliseconds (e.g., "1.2s", "500ms")
  formatBytes,   // Format bytes (e.g., "1.5 KB")
  formatNumber,  // Format numbers with locale
  formatPercent, // Format percentages
  truncate,      // Truncate text with ellipsis
} from "@codumentor/plugin-sdk";

Styles

The SDK provides CSS that integrates with the host's dark mode:

import {
  // All base styles combined
  getBaseStyles,

  // Individual style modules
  CSS_TOKENS,       // CSS custom properties (--color-*, --radius-*, etc.)
  CARD_STYLES,      // .card, .header, .details
  BADGE_STYLES,     // .badge
  STATUS_STYLES,    // .success, .error, .pending
  CODE_STYLES,      // code, pre
  LOADING_STYLES,   // .loading, .loading-spinner

  // Component styles
  BUTTON_STYLES,    // .btn, .btn-primary
  TABLE_STYLES,     // table, th, td
  LIST_STYLES,      // ul, li styles
  INPUT_STYLES,     // input, textarea
} from "@codumentor/plugin-sdk";

Always use CSS custom properties for colors to support dark mode:

.my-element {
  background: var(--color-bg-inset);      /* NOT #f5f5f5 */
  color: var(--color-text);                /* NOT #111 */
  border: 1px solid var(--color-border);   /* NOT #e0e0e0 */
}

Expand/Collapse Helper

import { bindExpandClick } from "@codumentor/plugin-sdk";

class MyCard extends BasePluginElement<MyData> {
  render() {
    this._shadowRoot.innerHTML = `...`;

    // Add click handler for expandable header
    bindExpandClick(this._shadowRoot, ".header", () => this.toggleExpanded());
  }
}

Using Other Frameworks

The build contract is framework-agnostic. Your plugin just needs to:

  1. Have a build script in package.json
  2. Output an ES module that registers custom elements
  3. Custom elements accept an event property

You can use:

FrameworkApproach
Vanilla TSExtend BasePluginElement or raw HTMLElement
LitUse @customElement decorator
SvelteCompile with customElement: true
Preact/ReactWrap with @preact/custom-element or similar
VueUse defineCustomElement()

Example with Lit

import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { PluginEvent } from "@codumentor/plugin-sdk";

@customElement("x-my-plugin-card")
export class MyPluginCard extends LitElement {
  @property({ type: Object })
  event?: PluginEvent<MyData>;

  render() {
    const data = this.event?.data;
    return html`<div class="card">${data?.message}</div>`;
  }
}

Welcome Page Widgets

Plugins can contribute widgets to the welcome page by adding a widgets array to the manifest:

{
  "ui": {
    "widgets": [{
      "id": "status_widget",
      "element": "x-my-status-widget",
      "title": "My Status",
      "placement": "welcome-sidebar"
    }]
  }
}

Widget Implementation with API Fetching

class MyStatusWidget extends HTMLElement {
  private _shadowRoot: ShadowRoot;
  private _config: { refresh_interval_seconds?: number } = {};

  constructor() {
    super();
    this._shadowRoot = this.attachShadow({ mode: "open" });
  }

  set config(value: { refresh_interval_seconds?: number }) {
    this._config = value || {};
    if (value?.refresh_interval_seconds) {
      setInterval(() => this.fetchData(), value.refresh_interval_seconds * 1000);
    }
  }

  connectedCallback() {
    this.fetchData();
  }

  async fetchData() {
    const response = await fetch("/ui/plugins/my_plugin/status/summary");
    const data = await response.json();
    this.render(data);
  }

  render(data: any) {
    this._shadowRoot.innerHTML = `
      <style>${getBaseStyles()}${WIDGET_STYLES}</style>
      <div class="widget">
        <div class="widget-header">${data.title}</div>
        <div class="widget-content">${data.count} items</div>
      </div>
    `;
  }
}

customElements.define("x-my-status-widget", MyStatusWidget);

Two-Tier Loading Pattern

For optimal performance, implement two API endpoints — a fast summary for initial render and a full dataset for expanded views:

# Backend API routes
@router.get("/status/summary")
async def get_summary(request: Request):
    # Fast endpoint for initial render (< 100ms)
    return {"count": 5, "top_items": [...], "has_more": True}

@router.get("/status")
async def get_full(request: Request):
    # Full data when user expands
    return {"items": [...]}

Building Plugins

Build Commands

From the ui/ directory:

# Build everything (plugins + main UI)
npm run build

# Build only plugins
npm run build:plugins

# Build with verbose output
npm run build:plugins:verbose

# Build a specific plugin
node scripts/build-plugins.js --plugin=telemetry

# Watch mode (in plugin directory)
cd plugins/my_plugin && npm run build:watch

Build Contract

The build system (ui/scripts/build-plugins.js) expects:

  1. package.json with a build script
  2. manifest.json with plugin metadata and ui.bundle path
  3. After npm run build, the file at manifest.ui.bundle must exist

The orchestrator:

  1. Builds the SDK first (ui/plugins/_sdk)
  2. Installs dependencies for each plugin if needed
  3. Runs npm run build for each plugin
  4. Copies bundles to ui/dist/plugins/{plugin_id}/

Development Workflow

  1. Start the dev server:

``bash
cd ui && npm run dev
``

  1. Build plugins when you make changes:

``bash
npm run build:plugins
# Or use watch mode in the plugin directory
``

  1. Refresh the browser to see changes (or use the plugin's watch mode + browser refresh)

Admin Panels

Plugins can contribute panels to the /admin hub page by declaring admin-panel as a placement and adding a widget with placement: "admin-panel".

Manifest Configuration

{
  "plugin_id": "my_plugin",
  "name": "My Plugin",
  "version": "1.0.0",
  "event_types": ["plugin.my_plugin.completed"],
  "ui": {
    "bundle": "index.js",
    "sdk_version": "1.0",
    "custom_elements": ["x-my-plugin-admin"],
    "placements": ["admin-panel"],
    "widgets": [{
      "id": "my_admin_panel",
      "element": "x-my-plugin-admin",
      "title": "My Plugin Settings",
      "placement": "admin-panel",
      "priority": 100
    }]
  }
}

Admin Panel Custom Element

Admin panel elements receive a config property (same pattern as welcome widgets) with optional endpoint and refresh configuration:

class MyPluginAdmin extends HTMLElement {
  private _shadowRoot: ShadowRoot;

  constructor() {
    super();
    this._shadowRoot = this.attachShadow({ mode: "open" });
  }

  set config(value: { refresh_interval_seconds?: number; summary_endpoint?: string; detail_endpoint?: string }) {
    // Use config for API endpoints or refresh intervals
  }

  connectedCallback() {
    this.render();
  }

  render() {
    this._shadowRoot.innerHTML = `
      <style>${getBaseStyles()}</style>
      <div class="admin-panel">
        <p>Plugin admin content here</p>
      </div>
    `;
  }
}

if (!customElements.get("x-my-plugin-admin")) {
  customElements.define("x-my-plugin-admin", MyPluginAdmin);
}

The admin hub automatically wraps each admin panel widget in a <section> with the widget's title as a heading. Widgets are sorted by priority (lower values appear first).


Ingestion Panels

Plugins can contribute panels inside the Ingestion page (/admin/ingest) — below the built-in full-ingestion and per-repo sections — by declaring ingestion-panel as a placement and adding a widget with placement: "ingestion-panel". Use this (instead of admin-panel) when your panel is conceptually part of ingestion/indexing and belongs next to those controls rather than on the standalone admin hub.

Manifest Configuration

Identical to an admin panel, only the placement string differs:

{
  "ui": {
    "custom_elements": ["x-my-plugin-ingestion"],
    "placements": ["ingestion-panel"],
    "widgets": [{
      "id": "my_ingestion_panel",
      "element": "x-my-plugin-ingestion",
      "title": "My Plugin Indexing",
      "placement": "ingestion-panel",
      "priority": 100
    }]
  }
}

Custom Element

Authoring is the same as an admin panel: the element receives the same config property (refresh_interval_seconds, summary_endpoint, detail_endpoint), and the host wraps each widget in a titled <section>, sorted by priority.

Both placements are rendered by the same reusable host — <PluginPanelHost placement=… /> backed by the usePluginPanels(placement) hook (ui/src/components/PluginPanelHost.tsx, ui/src/hooks/usePluginPanels.ts). AdminPage mounts placement="admin-panel"; IngestPage mounts placement="ingestion-panel". Adding a host to a new core page is just <PluginPanelHost placement="…" /> plus a new value in ExtensionPlacement (ui/src/types/api.ts).

Gating

The Ingestion page is already an admin-only route, so an ingestion-panel section is only visible to admins. This is visibility only — keep enforcing permissions on your plugin's own endpoints server-side (e.g. require_permission("admin:*")); the placement does not gate API calls.

The branch_switching plugin's branch-overlay panel is the canonical example (plugins/branch_switching/plugin.py ui_manifest; design rationale in doc/features/branch-overlay-admin-design.md §5, "Option C").


Chat Header Widgets

Plugins can contribute small components to the chat header. The header is a single responsive action bar: every item — native actions and plugin widgets alike — is placed by the same priority-ordered overflow pass, which keeps as many items inline as fit the available width and collapses the rest into the overflow (⋮) menu.

Each chat-header widget declares two layout fields:

Ordering inside the menu. priority is a single axis shared by the inline row and the menu — there is no separate "menu priority". The menu is one list that merges always-"menu" items with any auto items that happened to overflow for lack of space, then sorts the whole thing by descending priority (highest at the top). So a "menu" widget left at the default 50 will interleave with — and race against — whatever auto items got pushed out of the inline row on a given viewport. For a "menu" widget, prefer a negative priority so it settles near the bottom of the menu, below the space-driven overflow and in a stable spot. Use a high positive priority only when you deliberately want it near the top of the menu, accepting that it will sit among the overflowed auto items. Native items bracket the range: the user button anchors the top at 10000, logout the bottom at -10000.

Manifest Configuration

Add a widget with placement: "chat-header" in ui.widgets. (Declaring "chat-header" in ui.placements is optional and purely informational — discovery is driven by ui.widgets[].)

{
  "plugin_id": "my_plugin",
  "name": "My Plugin",
  "version": "1.0.0",
  "event_types": ["plugin.my_plugin.completed"],
  "ui": {
    "bundle": "index.js",
    "sdk_version": "1.0",
    "custom_elements": ["x-my-header-status"],
    "placements": ["chat-header"],
    "widgets": [{
      "id": "header_status",
      "element": "x-my-header-status",
      "title": "Status Indicator",
      "placement": "chat-header",
      "priority": 100,
      "collapse": "auto"
    }]
  }
}

When a widget is relocated between the inline row and the menu, the host sets a display-mode attribute ("inline" or "menu") on the custom element so it can adapt its rendering (e.g. show a label next to the icon in the menu).

Custom Element Properties

The host sets the following properties on the custom element:

PropertyTypeDescription
conversationIdstringCurrent conversation ID
localestringCurrent UI locale (e.g., "en", "hu")
widgetIdstringWidget ID from the manifest
pluginIdstringPlugin ID

Example Implementation

class MyHeaderStatus extends HTMLElement {
  private _shadowRoot: ShadowRoot;
  private _conversationId?: string;

  constructor() {
    super();
    this._shadowRoot = this.attachShadow({ mode: "open" });
  }

  set conversationId(value: string | undefined) {
    this._conversationId = value;
    this.render();
  }

  connectedCallback() {
    this.render();
  }

  render() {
    this._shadowRoot.innerHTML = `
      <style>
        :host { display: inline-flex; align-items: center; }
        .badge {
          display: inline-flex;
          align-items: center;
          gap: 4px;
          padding: 2px 8px;
          border-radius: 12px;
          background: var(--color-bg-inset);
          border: 1px solid var(--color-border);
          font-size: 12px;
          color: var(--color-text-secondary);
        }
        .dot {
          width: 6px;
          height: 6px;
          border-radius: 50%;
          background: var(--color-primary);
        }
      </style>
      <div class="badge">
        <span class="dot"></span>
        Connected
      </div>
    `;
  }
}

if (!customElements.get("x-my-header-status")) {
  customElements.define("x-my-header-status", MyHeaderStatus);
}

Multiple plugins can contribute to the header slot. They are rendered side by side, sorted by priority (lower values appear first, i.e., closer to the left).


UI Component Guidelines

  1. Always escape user content: Use escapeHtml() for any dynamic text
  2. Support dark mode: Use CSS custom properties, not hardcoded colors
  3. Use Shadow DOM: Provides style isolation from the host
  4. Handle all states: Render appropriately for pending, success, error
  5. Keep bundles small: The SDK is ~15KB; aim for <50KB total per plugin