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
- Copy the template:
``bash``
cp -r ui/plugins/_template ui/plugins/my_plugin
- 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:
transcript-cardfor plugin event cards in the transcriptmessage-actionsfor controls in the assistant turn action row (next to copy)chat-headerfor small components in the chat header (left of share button)welcome-widgetandsidebar-panelfor plugin-driven page/widget surfacesadmin-panelfor panels on the admin hub page (see Admin Panels)ingestion-panelfor panels inside the Ingestion page (see Ingestion Panels)
- 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"
}
}
- 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 = ${formatMs(duration_ms)}
<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 ? : ""};
</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 };
```
- 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/Method | Description |
|---|---|
_shadowRoot | Shadow DOM root |
_event | Full event object |
_data | Event data (typed as TData) |
_status | Current status |
_expanded | Expansion 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:
- Have a
buildscript inpackage.json - Output an ES module that registers custom elements
- Custom elements accept an
eventproperty
You can use:
| Framework | Approach |
|---|---|
| Vanilla TS | Extend BasePluginElement or raw HTMLElement |
| Lit | Use @customElement decorator |
| Svelte | Compile with customElement: true |
| Preact/React | Wrap with @preact/custom-element or similar |
| Vue | Use 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:
package.jsonwith abuildscriptmanifest.jsonwith plugin metadata andui.bundlepath- After
npm run build, the file atmanifest.ui.bundlemust exist
The orchestrator:
- Builds the SDK first (
ui/plugins/_sdk) - Installs dependencies for each plugin if needed
- Runs
npm run buildfor each plugin - Copies bundles to
ui/dist/plugins/{plugin_id}/
Development Workflow
- Start the dev server:
``bash``
cd ui && npm run dev
- Build plugins when you make changes:
``bash``
npm run build:plugins
# Or use watch mode in the plugin directory
- 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:
priority— higher = more important. Items are laid out left-to-right by descending priority, and when space runs out the lowest-priorityautoitems are the first to collapse into the menu. Defaults to50. Reference points: trial badge1000, share40, focus35, settings30(inline actions); file links-50, bug report10, export-100, admin-200(menu items).collapse— the collapse intent:"pinned": always inline, never collapsed (e.g. the trial badge)"menu": always in the overflow menu (e.g. the bug-report item)"auto"(default): inline while there is room, otherwise overflows into the menu
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:
| Property | Type | Description |
|---|---|---|
conversationId | string | Current conversation ID |
locale | string | Current UI locale (e.g., "en", "hu") |
widgetId | string | Widget ID from the manifest |
pluginId | string | Plugin 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
- Always escape user content: Use
escapeHtml()for any dynamic text - Support dark mode: Use CSS custom properties, not hardcoded colors
- Use Shadow DOM: Provides style isolation from the host
- Handle all states: Render appropriately for
pending,success,error - Keep bundles small: The SDK is ~15KB; aim for <50KB total per plugin