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 (the ExtensionPlacement union in
ui/src/types/api.ts):
| Placement | Where it renders |
|---|---|
| transcript-card | Plugin event cards in the transcript |
| message-actions | Controls in the assistant turn action row (next to copy) |
| chat-header | Small components in the chat header (see Chat Header Widgets) |
| header-menu | Legacy. Rewritten server-side to chat-header + collapse: "menu" |
| message-list-header | An in-flow row above the transcript's scroll viewport |
| composer-intent | Left of the composer textarea: what this turn does (see Composer Controls) |
| composer-input | Right of the composer textarea: how I am entering this message |
| composer-tray | The state row above the composer textarea (see the tray) |
| input-menu | Legacy. Rewritten server-side to composer-intent + collapse: "menu" |
| activity-panel | The activity side panel |
| welcome-widget, welcome-repos | Welcome page widget surfaces (see Welcome Page Widgets) |
| sidebar-panel | Plugin-driven sidebar surface |
| admin-panel | Panels on the admin hub page (see Admin Panels) |
| ingestion-panel | Panels inside the Ingestion page (see Ingestion Panels) |
The type is … | string, so a plugin may declare a custom slot; it renders
only where some host component mounts that placement.
- Update
package.json:
``json``
{
"name": "@codumentor/plugin-my-plugin",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "node ../../scripts/plugin-esbuild.js",
"build:watch": "node ../../scripts/plugin-esbuild.js --watch",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@codumentor/plugin-sdk": "file:../_sdk",
"esbuild": "^0.20.0",
"typescript": "^5.3.0"
}
}
The esbuild flags themselves live in ui/scripts/plugin-esbuild.js, not
here — every plugin runs the same ones. Two of them matter to you:
--minify, so bundles ship minified.- The SDK is external.
@codumentor/plugin-sdk(and its/stylesand/utilssubpaths) is rewritten to/ui/extensions/sdk/index.js, the one shared SDK bundle the browser loads once, rather than inlined into your bundle. Which is why your bundle is a few KB rather than 110 KB, and whyui/plugins/_sdk/index.jshas to be built before yours — the orchestrator (npm run build:plugins) does that for you.
- 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 (call this from your click handler) |
setExpanded(value) | Set expansion state directly (same channel as above) |
expanded | Property the host sets to restore expansion; also readable |
render() | Abstract — you must implement this |
Both expansion setters reflect an expanded attribute onto your element. The
app's stylesheet cannot see inside your shadow root, so that attribute is how
the transcript learns a card grew: an open card claims extra columns in a
multi-card row, and one that asked for a wide open state gets it (below).
The host sets the event property, which triggers render() automatically.
Expansion is held by the host, not by your element
Your element does not outlive the transcript's layout decisions. A lone card
becomes a member of a .plugin-card-row the moment a second plugin event
arrives in the same run, drops back out on re-materialization, and moves
between renderers when focus mode is toggled. Each of those re-parents the
element, which means the browser destroys it and the host builds a new one —
taking _expanded with it. A card that closed itself every time a neighbour
arrived was the result.
So the state lives above the card, keyed by the plugin event id, and the two
halves of BasePluginElement keep it in sync:
setExpanded/toggleExpanded— you call these, from a click handler. Besides re-rendering and reflecting the attribute, they dispatch a bubblingPLUGIN_CARD_EXPANDED_EVENT("codumentor:plugin-card-expanded", detail{ expanded }) that tells the host the user opened or closed this card.expanded— the host sets this property, on a freshly built element, to whatever it remembers. It applies the state silently: it does not dispatch the event, so state coming down can never echo back up.
None of that needs anything from your card beyond routing every toggle through
setExpanded/toggleExpanded — which bindExpandClick already does. Do not
assign this._expanded directly: the host would never hear about it, and the
next re-parenting would undo it.
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";
To hand the user off to the chat, startNewChat({ prefill }) opens a new,
empty conversation with prefill in its composer — never sent, so the user
reviews it first. It works from a full page too (the host navigates to the
chat). See the SDK README, "Starting a new chat".
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 */
}
Role tokens and "edge, not fill"
The host app's colors are not decorative — each accent has exactly one job.
doc/core-dev/design/visual-language.md
is the source of truth; the short version for plugin authors:
--accent-live— interactive or in-flight. Only for something clickable right now or actively working/streaming.--accent-user— the human's voice. Not available to plugin content; don't use it for emphasis or status.--accent-inner— the agent's inner work (thinking/subagent-shaped content). Recessed only — a low-alpha tint, never full strength.--color-success/--color-warning/--color-error(plus their-textvariants) — outcome only: done, needs attention, or failed.
Plugin cards read as neutral with a colored edge, not a colored fill. The
SDK's own sdk-card-info|success|warning|error classes put the role/status
color on the card's left border only (plugin-sdk.css); the card surface
itself stays --color-bg-inset. Follow the same pattern in your own markup —
a status color filling the whole card is what the design doc calls out as the
mistake to avoid, and it is inconsistent with how the core app's own cards
(tool cards, thinking blocks) render.
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());
}
}
Cards that open into a document
A card whose open state is a document rather than a few metric tiles asks the
transcript for room by putting open_width: "wide" in the event data, next to
align:
await ui_event_emitter("plugin.my_plugin.thing_happened", {
"status": "success",
"summary": "…",
"align": "right",
"open_width": "wide", # two thirds of the transcript column while open
}, turn_id)
Closed, the card is unaffected — still a one-line chip. While open (i.e. while
the expanded attribute above is set) its container widens to two thirds of
the transcript column, or the whole of it below 700px. Your own wrapper must
stop capping itself for that to be visible:
.wrapper.open { display: block; max-width: none; width: 100%; }
To render markdown inside such a card, use the SDK's renderer with
escapeRawHtml whenever the text is model output or comes from repository or
conversation content — marked passes embedded HTML straight through, and the
result goes into your shadow root via innerHTML:
import { renderMarkdownHtml, MARKDOWN_PREVIEW_STYLES } from "@codumentor/plugin-sdk";
const html = renderMarkdownHtml(text, { escapeRawHtml: true });
MARKDOWN_PREVIEW_STYLES carries the matching block styles (it targets
.markdown-content) and follows the host theme.
Offering "open these cards by default"
A card whose open state is worth reading every time can offer the reader a
setting for it, without any front-end change. Declare a boolean in your
settings_schema and mark it "opens_cards": True:
def ui_manifest(self) -> dict:
return {
"settings_schema": {
"properties": {
"open_summary_cards": {
"type": "boolean",
"title": "Open summary cards by default",
"default": False,
"opens_cards": True,
},
},
},
}
The transcript resolves the flag generically (ui/src/state/hiddenPluginCards.ts)
and every card of that plugin mounts with expanded already set. Notes:
- It is the mirror of
"hides_cards": True, the flag that lets a boolean switch your cards off. A plugin may declare both; they are independent. - An unset value falls back to the declared
default, so the toggle reads the same in the settings panel as the transcript behaves. PreferFalse: a default-open document at every event is a lot of transcript to scroll past. - It is a default, not a lock. The reader closing such a card is remembered for the rest of the conversation, and that decision beats the setting.
- Nothing in your plugin reads the value back — the host does. That is also why the "every declared setting must be read by something" tripwire (
tests/test_plugin_settings_read_sites.py) accepts the flag as the answer.
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}/
Code splitting (opt-in). If your bundle is eager (a chat-header or welcome
widget) but also carries a heavy page app, set
"pluginBuild": { "splitting": true } in package.json and import() the
app lazily from its element. The build then emits chunk-<hash>.js files flat
beside index.js; they are served, packaged and git-ignored like it. The entry
must still define every declared custom element. Example: ui/plugins/goals.
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)
Settings panes
A plugin's per-user settings appear as its row under Settings → Extensions.
ui_manifest() declares what the row renders: a settings_schema (the host
builds a form), a settings_widget (your own custom element, with
"web_component": "x-…" and the tag in ui.custom_elements with the
settings-panel placement), an oauth_connection, or several of them. In the
TUI the row mounts the Textual widget your plugin returns from
tui_settings_widget(user_id).
A plugin may instead put those same settings in a pane at the root of the
Settings sidebar by declaring settings_pane.
Most plugins should not declare a
settings_pane. Usesettings_schema
orsettings_widget: they appear under Settings → Extensions, which is where
users look for a plugin's options. A root pane lengthens the Settings sidebar
for every user of every deployment that loads your plugin.A root pane is for a plugin that meets all three of these:
- it owns a product-level concern that most users visit, such as the models a conversation runs on;
- without it, the concern would have to be a core section;
- its pane stands on its own rather than listing options.
If you are unsure, it does not qualify. Raise it in review before adding one.
def ui_manifest(self):
return {
# … existing keys, including what the pane renders:
"settings_widget": {"web_component": "x-my-plugin-settings"},
"settings_pane": {
"id": "models", # slug, unique per process
"title": {"en": "Models", "hu": "Modellek"},
"icon": "model", # host icon token
"order": 10, # among plugin panes
# "access_feature": "<plugin>.<feature>", # optional
},
}
| Field | Required | Meaning |
|---|---|---|
id | Yes | A slug ([a-z][a-z0-9_-]*, at most 40 characters), unique per process. The web section id is plugin:<id>. |
title | Yes | The nav label by locale. en is required; other locales fall back to it. |
icon | No | A token from the host set: model, key, link, user, chart, database, globe. Anything else, or none, renders a generic glyph. You cannot supply your own SVG. |
order | No | Position among plugin panes (default 100); ties sort by id. |
access_feature | No | The access feature the pane belongs to. |
What the host does with it:
- Position is banded. The sidebar is General, Tool Approvals, Shared, the plugin panes (by
order, thenid), Extensions, About. A plugin cannot reorder, replace or sit between core items. - One rendering path. The pane's body is exactly what your Extensions row would have rendered — schema form,
settings_widget,oauth_connection— under the pane's localized title. A plugin with a pane has no Extensions row. A pane with nothing to render is not shown. - One pane per plugin, first come first served. A duplicate
idgoes to the plugin loaded first (priority order, which is deterministic). The other plugin's pane is refused with an error log naming both, and that plugin keeps its Extensions row. Ownership is decided over every loaded plugin's declaration before any access check, so it is the same for every user: a pane withheld from one user does not pass itsidto the next plugin. An invalid declaration (noentitle, anidthat is not a slug) is refused the same way. - Gating.
access_featureonsettings_panewithholds the pane from a principal without the feature — and your settings with it: they do not fall back to an Extensions row.access_featureonsettings_widgetwithholds just the widget. An edition that excludes your plugin sends no manifest, so no pane. - Deep links.
openSettings("plugin:<id>")opens the pane. An olderopenSettings("extensions", { pluginId })for a plugin that now owns a pane is redirected to the pane, so existing links keep working; a link to a pane that is not there falls back to the plugin's Extensions row, then General. - TUI. The pane gets its own entry in the Settings sidebar, before Plugin Settings, mounting your
tui_settings_widget(and your schema form, if you have one) — also when your plugin has no web settings at all. The Plugin Settings accordion skips your plugin. There is no new TUI hook. - Operators can see it. Startup logs one line listing the plugin panes in force (
Settings panes from plugins: models (thinking_modes)), so an operator can see what lengthened their sidebar.
The in-tree users are thinking_modes, whose pane is Settings → Models
(catalog), and the agent_instructions builtin,
whose pane is Settings → Custom instructions
(catalog).
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").
Full Pages
A widget lives inside someone else's layout. When your plugin needs a whole
screen — a browser, a dashboard, a management console — declare a page
instead. Each entry in ui.pages mounts your custom element at its own SPA
route:
"ui": {
"bundle_url": "/ui/extensions/plugins/my_plugin/index.js",
"custom_elements": ["x-my-plugin-browser"],
"pages": [
{
"page_id": "browser",
"path": "browser", # relative, no leading slash
"title": "Browser", # shown as the page heading
"custom_element": "x-my-plugin-browser",
},
],
}
| Field | Description |
|---|---|
page_id | Stable identifier for the page within your plugin |
path | Route segment, relative — the host mounts it under /plugins/<plugin_id>/ |
title | Human-readable page title |
custom_element | Tag from your bundle to mount as the page body |
The example above is reachable at /plugins/my_plugin/browser. Routing goes
through App.tsx's /plugins/:pluginId/* entry, so you add no React code and
touch no core routing.
Carry page state in the URL hash, not in component state, so deep links from
a transcript card restore the full view:
/plugins/file_links/repos#?repo=alpha&path=docs/report.md
file_links (repo browser), agentic_memory, llm_server, log_collection
and user_feedback all ship pages this way.
Several pages, one element. When several ui.pages entries name the same
custom_element, the host keeps that one instance while the user moves between
them and only updates its page-id attribute; a different element remounts.
So a plugin can run its own in-app router: history.pushState plus a
dispatched popstate changes the URL, the host re-matches the page, and your
element keeps its state. goals does this (ui/plugins/goals/src/paths.ts).
Pages are not gated for you. The route renders for any authenticated user;
enforce authorization on the plugin API endpoints the page calls, exactly as
with admin panels.
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, focus40, share35, settings30(inline actions); file links-50, 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 file-links 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 laid out side by
side in descending priority — the highest-priority item sits leftmost, and
the lowest-priority auto items are the first to collapse into the ⋮ menu when
space runs out (ui/src/components/headerOverflow.ts).
Note: this is the opposite of
admin-panel/ingestion-panel, where
panels sort by ascending priority (lower value first). The chat header sorts
descending because therepriorityexpresses importance — what survives
inline when the bar is tight — not just position.
Message Actions
A message-actions widget renders in the small row under an assistant answer,
next to copy (ui/src/components/MessageActions.tsx). The row appears after
every completed answer, in normal and focus mode alike, so one element is
mounted per answer. Widgets sort by ascending priority.
Element context
The host sets context (a MessageActionsContext from
@codumentor/plugin-sdk) and also each field as a property of the same name,
plus widgetId, pluginId and locale:
| Field | Type | Attribute | Meaning |
|---|---|---|---|
conversationId | string? | conversation-id | The conversation |
turnId | string? | turn-id | Root turn id (the user message id), when known |
assistantMessageId | string? | assistant-message-id | The answer's message id |
assistantContent | string | — | The turn's final assistant text |
isLatestTurn | boolean | latest-turn (present when true) | This row is under the conversation's latest answer |
turnOutcome | `"completed" \ | "cancelled" \ | "error"` |
canSend | boolean | can-send (present when true) | The viewer can send into this conversation from here |
- Latest and responding. At most one row is the latest, and only while nothing is in flight: the latest row is not rendered while the conversation is responding or a send is pending. When a turn starts, that element is removed (normal mode) or every row is removed (focus mode), so a widget that shows only on the latest row needs no separate "responding" signal. A trailing turn with no answer (cancelled before any text) has no row, and the answer above it is not the latest either.
- Outcome.
cancelledwhen the turn carries a cancel notice;errorwhen it is the latest turn and ended withturn.failed. An earlier failed turn reportscompleted, because only the latest turn's failure is known. A turn waiting on an approval is still in flight, so it has no latest row. - Can send. False in the admin chat viewer and in a conversation from another folder (the composer is blocked there). Share views and subagent transcripts do not render this row at all.
isLatestTurn,turnOutcomeandcanSendcan change while the element is mounted. The host updates the properties and attributes in place and assigns a newcontextobject, so acontextsetter (orattributeChangedCallbackon the attributes) sees each change. The other fields remount the element.
To send a user turn from a widget, dispatch the window event
codumentor:send-chat-message with { conversation_id, text }. By default the
composer is cleared, as when the user presses Send; add preserve_draft: true
to leave a half-typed draft alone.
Composer Controls
The composer row — the one with the textarea and the send button — is two
placements, not one, split by what a control is rather than where it happens
to sit:
| Placement | Side | It answers | Overflow |
|---|---|---|---|
composer-intent | left of the textarea | what this turn does | into the + menu |
composer-input | right of the textarea | how I am entering this message | icon-only, then gone |
The split exists because one overflow policy cannot serve both. A mode picker
works fine as a dropdown row: you open it, you choose, you close. A
press-and-hold microphone does not — so the input cluster is never put in a
menu. Which side a new control belongs on therefore stops being a judgement
call.
Both clusters go through one measured pass over their union, so the globally
least important control on the row collapses first regardless of side; only what
happens to it comes from its own placement.
Layout fields
priority— higher = more important, exactly aschat-header: laid out furthest from the textarea, and last to collapse. Defaults to50. Reference points among the native controls: thinking chip60, speak-replies40(intent); push-to-talk50, voice conversation30, native dictation20(input). On the input side the highest priority renders nearest the send button, in the thumb zone, so the visual order there is the reverse of the priority order.collapse— what happens when the row runs out of room:"pinned": always on the row. It guarantees presence, never a label — a pinned control still degrades to icon-only when the budget is tight."menu": always in the+menu.composer-intentonly; an input control that declares it is simply absent, since that cluster has no menu."auto"(default): on the row while there is room, then icon-only, then demoted — into the menu forcomposer-intent, out of sight forcomposer-input."hide": likeauto, but with no menu fallback. For a control that is a redundant convenience (native dictation is one) rather than the only way to reach something.group(composer-inputonly) — an optional merge group. Accepted today so a manifest can declare it; a single merged trigger for the group is not implemented yet.
The three display modes
There is no way for a widget to demand a label. Instead the host sets
display-mode on the custom element and the widget must render sanely in
all three:
display-mode | What the host is asking for |
|---|---|
"inline" | The full trigger — icon and label |
"compact" | Icon only. Presence beats labels when the row is tight |
"menu" | A labelled row inside the + dropdown |
Presence comes first: the host places every control compact, sees who fits, then
hands labels back from the highest priority down. A control that renders nothing
at "compact" will simply vanish at a width where it could have stayed.
The element is moved, never re-created
A composer widget is instantiated once and relocated between the row and
the menu — and it stays alive, detached, while the menu is shut. That is what
lets a demoted widget go on tracking the state it will show when the user
finally opens the +.
Subscribe in the
constructor, notconnectedCallback. A widget demoted
into a closed menu has no DOM at all, soconnectedCallbackhas not run and
will not run again until the menu opens. This is the same rule
chat-header widgets follow, and for the same reason.
Menu behaviour
Rows in the + close the menu when clicked, which is what an entry that acts
once and is done wants. A widget that owns a toggle, a stepper, or anything with
more than one hit target opts out by putting data-keep-open on itself — the
host reads it at click time, so a widget may change its mind — and then closes
the menu itself when it is finished.
The dropdown is a role="menu" whose native rows are role="menuitem", walked
with the arrow keys. A plugin's row is not given that role: the host cannot
activate a control that lives inside your shadow root, so keyboard access is
yours to provide — render a focusable element (a <button>) in an open shadow
root and it is reachable by Tab.
The tray, and the state a control publishes
Demotion would otherwise be lossy: a control pushed into a shut + takes its
state with it, and the user has to open a menu to find out that replies are
being read aloud or which mode this turn runs at. The composer tray — an
in-flow, wrapping row directly above the textarea — is where that state goes on
being readable with nothing opened.
The host draws the chip, never the widget. A widget is one element and
cannot be in two DOM places at once, so drawing its own chip would cost it the
"moved, never re-created" guarantee above. Instead it declares its state, and
the host renders it — which also means every plugin's chip is styled the same.
Publish in either spelling. Both carry the same fields:
| Field | Attribute | Meaning |
|---|---|---|
| label | data-state-label | Short text, glyph included if you want one (◐ Slow · High). No label ⇒ nothing published. |
| tone | data-state-tone | default (the host stays quiet) \ |
| count | data-state-count | A badge, rendered only above 1. |
| actionable | data-state-actionable | Optional chip activation opt-in: only boolean true in event detail or the exact attribute value "true" enables it. Omitted or false stays passive. |
// Attributes — read the moment the host creates your element, so this is the
// spelling that carries *initial* state.
this.setAttribute("data-state-label", "3 folders");
this.setAttribute("data-state-tone", "active");
this.setAttribute("data-state-count", "3");
// Or an event on yourself, for an update you already have in hand.
this.dispatchEvent(new CustomEvent("codumentor:composer-state", {
detail: { label: "3 folders", tone: "active", count: 3 },
}));
Two rules follow from the contract:
defaultis silence. A chip appears only for a non-default state, which is what lets the tray cost exactly zero pixels on a conversation with nothing to say. Publishdefault(or no label) when you are sitting at the value the user would have got anyway.- A chip is drawn only while your control is off the row. On the row your own trigger is showing; a chip beside it would be the same string twice.
Opting in to chip activation
Chips are informational and passive by default. A plugin that can handle
activation opts in with data-state-actionable="true" or actionable: true in
its codumentor:composer-state detail. String "true" in event detail, an empty
attribute, and other truthy values do not opt in.
When the user activates an actionable chip, the host first opens the overflow
(+) menu, then dispatches COMPOSER_ACTIVATE_EVENT
("codumentor:composer-activate") directly on the existing plugin element
with detail { source: "chip" }. The element is not recreated. Listen on the
element itself and open or focus your own control; the host does not synthesize
a click inside your shadow root.
import { COMPOSER_ACTIVATE_EVENT } from "@codumentor/plugin-sdk";
// Install once, for example in your element's constructor.
this.addEventListener(COMPOSER_ACTIVATE_EVENT, (event) => {
const { source } = (event as CustomEvent<{ source: "chip" }>).detail;
if (source === "chip") {
this.shadowRoot?.querySelector<HTMLButtonElement>("button")?.focus();
}
});
this.setAttribute("data-state-actionable", "true");
Removing the attribute or publishing state without actionable: true makes the
chip passive again. Attribute and event updates remain last-writer-wins.
A control that needs a genuine custom in-flow row rather than a chip — something
InputAttachments-shaped — declares placement: "composer-tray" and renders
into the row itself.
What the + says on your behalf
The tray carries the label; the + carries the fact that your control is in
there. When the row runs out of space and pushes controls into the menu, the
trigger grows a dot — a count once it is holding more than one, and the same
number in its accessible name, since a dot is invisible to a screen reader. Your
published tone colours it: the loudest one among everything demoted wins.
Two consequences worth knowing:
- A
collapse: "menu"widget never marks the trigger. It declared the menu its home, so nothing was lost by it being there, and a dot that is always present would say nothing. Only controls the row had to give up are counted. - The dot appears even when you published nothing. That is the case it exists for:
defaultis silence in the tray, so without it a control could be demoted with no trace at all. You do not have to publish state to stay discoverable — publishing is what upgrades the dot to a readable chip.
The tray sorts by ascending priority (left to right, like panels), the
opposite of the two row clusters beside it. The clusters sort descending
because therepriorityexpresses importance, which is what decides who
survives when the row is tight; the tray never collapses, so there it is only
position.
Manifest configuration
{
"widgets": [{
"widget_id": "turn_scope",
"title": "Turn scope",
"placement": "composer-intent",
"priority": 45,
"collapse": "auto",
"custom_element": "x-my-turn-scope"
}]
}
The custom element receives the same properties as a header widget
(conversationId, locale, widgetId, pluginId) plus the display-mode
attribute above.
Migrating from input-menu
Nothing to do. A widget that still declares placement: "input-menu" is
rewritten on the way out of the API to composer-intent + collapse: "menu",
with its priority negated — input-menu sorted ascending and
composer-intent sorts descending, so flipping the sign is what preserves the
relative order of a plugin's own entries while landing them near the bottom of
the menu. Declare the new placement when you want the host to be able to put the
control on the row as well.
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
Settings action widgets
Plugins may declare ui.widgets[] entries with placement: "settings-panel"
to add actions to the Settings sidebar, before About. These are distinct from
settings_widget forms and root settings_pane sections. The host loads each
custom element on demand when Settings opens, ordered by descending priority
then plugin/widget ID, and supplies locale, plugin-id, widget-id, and the
active conversation-id (omitted without a conversation). The bug-report plugin
uses this surface to open its report dialog without a main-menu entry.