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 (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.

  1. 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:

  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 (call this from your click handler)
setExpanded(value)Set expansion state directly (same channel as above)
expandedProperty 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:

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:

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:


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}/

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

  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)

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. Use settings_schema
or settings_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
        },
    }
FieldRequiredMeaning
idYesA slug ([a-z][a-z0-9_-]*, at most 40 characters), unique per process. The web section id is plugin:<id>.
titleYesThe nav label by locale. en is required; other locales fall back to it.
iconNoA 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.
orderNoPosition among plugin panes (default 100); ties sort by id.
access_featureNoThe access feature the pane belongs to.

What the host does with it:

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",
        },
    ],
}
FieldDescription
page_idStable identifier for the page within your plugin
pathRoute segment, relative — the host mounts it under /plugins/<plugin_id>/
titleHuman-readable page title
custom_elementTag 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:

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 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 there priority expresses 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:

FieldTypeAttributeMeaning
conversationIdstring?conversation-idThe conversation
turnIdstring?turn-idRoot turn id (the user message id), when known
assistantMessageIdstring?assistant-message-idThe answer's message id
assistantContentstring—The turn's final assistant text
isLatestTurnbooleanlatest-turn (present when true)This row is under the conversation's latest answer
turnOutcome`"completed" \"cancelled" \"error"`
canSendbooleancan-send (present when true)The viewer can send into this conversation from here

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:

PlacementSideIt answersOverflow
composer-intentleft of the textareawhat this turn doesinto the + menu
composer-inputright of the textareahow I am entering this messageicon-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

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-modeWhat 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, not connectedCallback. A widget demoted
into a closed menu has no DOM at all, so connectedCallback has 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.

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:

FieldAttributeMeaning
labeldata-state-labelShort text, glyph included if you want one (◐ Slow · High). No label ⇒ nothing published.
tonedata-state-tonedefault (the host stays quiet) \
countdata-state-countA badge, rendered only above 1.
actionabledata-state-actionableOptional 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:

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:

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 there priority expresses 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

  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

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.