ade
(alias: console)v1.9.30Web console for iii — bundles the React UI and proxies the engine WebSocket on a single port.
- macOS: arm64
- Linux: arm64 · armv7 · x64
- Windows: arm64 · x64
exact versions are immutable; binary and bundle artifacts are digest-pinned.
skill doc
Injectable console UI
A worker can ship pages, function and trigger-activity renderers, forms, and stylesheets into every console tab
at runtime—no rebuild, no iframe, and hot reload. Treat database, console functions/triggers,
iii-directory, and state as proven patterns, not visual templates: reuse
their visual grammar and interaction mechanics while choosing the information
architecture that best fits the worker.
How it works
A worker registers console:script and console:style triggers whose
config.path identifies an asset and whose function_id serves {content}
for {path}. The console hashes and serves those bytes, then pushes changes
to open tabs. Tabs import() scripts and call their default setup(host);
styles load as scoped assets. Re-registering a path hot-reloads it.
Registration is deployment; disconnect is teardown.
Add the internal dependencies
This repository versions both sides of the contract together. Do not try to install them from a public registry:
Add
to the root/ui pnpm-workspace.yaml.Add the compile-time UI surface to
:/ui/package.json { "name": "@iii-workers/mywork-ui", "private": true, "version": "0.0.0", "type": "module", "scripts": { "build": "tsc --noEmit && node build.mjs", "watch": "node build.mjs --watch" }, "dependencies": { "@iii-dev/console-ui": "workspace:*" }, "devDependencies": { "@types/react": "^19.2.14", "esbuild": "^0.25.0", "typescript": "^5.9.2" } }For a Rust worker, link the worker-side registration helper in
:/Cargo.toml iii-console-ui = { path = "../crates/console-ui" }
@iii-dev/console-ui is types-only at build time; the console serves its
runtime implementation from the active SPA. iii-console-ui registers the
content function, asset triggers, and development watcher. Node workers have
no worker-side helper and implement the wire contract directly.
Project layout
mywork/
build.rs # ensure dist assets exist before include_str!
ui/
page.tsx # the script asset — default-exports setup(host)
styles.css # the style asset — every rule scoped
build.mjs # esbuild, five external specifiers
package.json # workspace dependency on @iii-dev/console-ui
tsconfig.json
src/ # page, renderer, config form, hooks, widgets
src/
ui.rs # embed and register dist/page.js + dist/styles.cssStart from state/ui/tsconfig.json, state/build.rs, and state/src/ui.rs
for the mechanical files; rename worker/asset paths and keep their tests.
For UI structure, consult the references below before writing code.
Authoring workflow
- Read
packages/console-ui/index.d.ts; never guess a component or prop. - Select only the needed slots, then model the primary object, navigation, actions, async states, and state that must survive navigation or reload.
- Design wide and narrow flows deliberately; do not squeeze desktop UI.
- Build with shared primitives and minimal scoped CSS, then type-check, register, inspect the manifest, and exercise the real console.
Living references
| Need | Read | Reuse |
|---|---|---|
| Public API | packages/console-ui/index.d.ts |
Exact exports and props |
| Shared page chrome | ade/web/src/components/ui/PageChrome.tsx |
PageShell, PageHeader, surface roles |
| Catalog/detail | ade/ui/src/catalog/widgets.tsx, ade/ui/src/catalog/FunctionsPage.tsx, ade/ui/src/catalog/TriggersPage.tsx, ade/ui/styles.css |
Grouped rows, persistent hero, identity masthead, facts, tabs, contextual rail |
| Data workbench | database/ui/src/page/index.tsx, database/ui/src/page/TableDataPanel.tsx, database/ui/styles.css |
Mode bar, schema tree, toolbars, data grid, inspector, nested container responses |
| List/detail editor | iii-directory/ui/page.tsx, iii-directory/ui/src/page/browser.tsx, iii-directory/ui/styles.css |
setup(host), container-width drill-in, dirty-draft guards, per-tab state |
| Multi-level browser | state/ui/page.tsx, state/ui/src/page/browser.tsx, state/ui/styles.css |
One-pane-at-a-time narrow flow, live state updates, stale-request guards |
| Rust delivery | state/src/ui.rs, state/build.rs |
Embedding, registration, asset tests, build freshness |
Copy delivery plumbing when it matches. Do not copy a reference page's sidebar count, breakpoints, controls, or visual hierarchy without deriving them from the new worker's content.
Visual quality is part of correctness
Choose one dominant archetype before writing JSX. Mixing all four produces a generic dashboard with too many panels.
| Archetype | Use for | Required shape |
|---|---|---|
| Console catalog | Many searchable objects with rich detail | Grouped list → persistent hero or breadcrumb + identity masthead + tabs; add a contextual rail only for genuinely related information |
| Database workbench | Several tools operating on one selected resource | Compact mode switcher, collapsible resource tree, one active work surface, local toolbar/status bar, optional inspector |
| Directory editor | Searchable documents with drafts or preview | List → document identity → edit/preview modes; keep draft state mounted and put save status beside the work |
| State explorer | Deep but compact hierarchy | Progressive columns on wide panes and one-level-at-a-time drill-in on narrow panes |
Apply the shared visual grammar
- Build hierarchy with surfaces, not boxes: sidebar, panel, raised toolbar, hover/selected wash. Reserve 1 px edges for structural or tabular separation; avoid borders, shadows, or a card around every section.
- Elevation is four shared tokens, never a hand-rolled stack:
--shadow-raisedfor a card sitting on a panel,--shadow-floatingfor menus, popovers, and sheets,--shadow-liftfor an instrument surface that must read as lifted off the canvas with a crisp edge (the chat composer), and--shadow-keycapfor a key cap (the lift turned upside down, so the key reads as set into the surface). Each is a complete, theme-awarebox-shadowvalue — writebox-shadow: var(--shadow-lift)and nothing else: no border, ring, or extra drop beside it, and never a literal shadow color. - Use a restrained scale: 4/6/8 px for internal gaps, 12/14/20/24 px for section spacing, and the system 6 px radius. Oversized padding makes these dense operator tools look like marketing pages.
- Set document/hero titles around 17–18 px at weight 600; body copy around
12.5–13 px with 1.55–1.65 line height and a 60–72ch measure; metadata around
10–11.5 px. Author interface copy in natural sentence/title case; never use
CSS
lowercaseoruppercasetransforms on tabs, buttons, menus, or forms. - Use sans for all interface chrome, labels, actions, explanations, and prose. Reserve mono for machine-produced ids, paths, schemas, values, payloads, code, and tabular data. Never make a whole panel or its controls mono.
- Repeat one restrained identity glyph in the list row, empty hero, and detail masthead, as console functions/triggers do. Use Lucide icons at the shared 16 px baseline; do not add application icon usages, component defaults, or root SVGs below 16 px, emoji, or a new icon dependency.
- Make list rows full-width targets with one strong primary line and at most one or two quieter supporting lines. Indicate selection with a surface wash, stronger ink, and an optional 2 px neutral edge—never accent color alone.
- Build a compact sidebar hierarchy (sessions and their sub-agents, folders,
scopes) on the
uiClasses.tree*recipe rather than a private row: 28 px rows in 13 px/500 sans, one 16 px glyph tinted throughdata-colorwith the shared glyph tones, 14 px of indent per level set as--iii-ui-tree-depthon the row, the disclosure caret right after the label, quiet metadata and a hover-revealed X (LucideX, never a trash can) on the trailing edge. Setdata-narrowon the tree when the pane is a phone-sized drill-in so rows and controls grow to touch size. - Keep page actions in
PageHeader; put resource actions in the identity masthead and work actions in the nearest toolbar. Show one clear primary action at the point of work; move rare actions into a menu. - Use shared line
Tabsfor peer views of the same object. They have a bottom rule, neutral active underline, 600 weight, natural casing, and a semantic 16 px icon by default.SegmentedControl variant="tabs"uses the same line recipe; reservevariant="radio"and its surface track for persisted mutually exclusive choices. Do not fork private boxed tab CSS. - Use compact fact sheets or stat tiles only for useful comparisons. Prefer a
quiet
--color-surfacegroup with label/value rows over a grid of large KPI cards. - Put loading, empty, error, and success states where content will appear so
the page silhouette stays stable. Use
Skeleton,EmptyState, andStatusPanel; never present raw error text as the main design. - For simple tables, compose the shared
TableViewport/TableFrame/Tablefamily. Use natural-case sans headers, horizontal row dividers, comfortable page density or compact chat density, and mono only for technical cells. Make only interactive rows hoverable. Long data grids may add sticky headers, aligned tabular numbers, selection, and an inspector or context rail.
Reject generic generated UI
Do not ship card soup, gradients, glows, ornamental shadows, giant centered headings, excessive badges, random accent colors, repeated descriptions, or an empty canvas with controls floating in corners. Do not give navigation, metadata, and the primary task equal visual weight. Compare the result beside the closest reference at the same width in both themes; its structure may differ, but density, typography, surface hierarchy, and control treatment must feel native to the same console.
1. The script asset (ui/page.tsx)
Ordinary React. Import from react and @iii-dev/console-ui — both resolve
at runtime through the console's import map, so they must stay external
in your build. Default-export a setup(host) function and make every
registration through host (the loader attributes registrations to your
script so it can dispose them on reload):
import {
type Host,
PageHeader,
PageMain,
type PageRenderProps,
PageShell,
} from '@iii-dev/console-ui'
function MyworkPage({
host,
onRequestClose,
}: PageRenderProps & { host: Host }) {
return (
<PageShell className="mywork-ui-shell">
<PageHeader
icon={<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" aria-hidden><circle cx="8" cy="8" r="5" /></svg>}
title="Mywork"
description={host.path}
onClose={onRequestClose}
/>
<PageMain className="mywork-ui-main">
{/* Compose the chosen archetype here. */}
</PageMain>
</PageShell>
)
}
export default function setup(host: Host) {
host.pages.register({
id: 'mywork-manager', // page URL: #/ext/mywork-manager
title: 'Mywork', // nav label
configurationId: 'mywork', // host adds the standard settings action
render: (props) => <MyworkPage host={host} {...props} />,
})
// Register other slots only when their implementations exist.
// host.functionTriggers.register(createMyTriggerRenderer(host))
// host.triggerRenderers?.register(createMyTriggerActivityRenderer())
// host.configForms.register('mywork', MyConfigForm)
// host.providerConfigForms?.register('my-provider', MyProviderConfigForm)
}This is a delivery skeleton, not a finished design. Compose one archetype in its body before evaluating the UI. Imports from the shared package add zero bundle bytes because they resolve to the running console's React tree.
The shared component library
The package exports page chrome; List/ListItem, Card, CollapsibleCard, CardHighlight, Panel, Chip,
IconButton, and semantic Table parts; line Tabs and SegmentedControl;
Selector and Select; buttons, inputs, dialogs, menus and tooltips;
status/empty/loading components; Markdown and JSON renderers; the terminal atoms (AnsiText,
TerminalStream, TerminalCommandLine); CodeEditor, FileDiff; and
settings primitives (SettingsSection, SettingsList, SettingsRow,
SettingsField, RawValueInput, SettingsDeck, Switch). It also exports the stable uiClasses recipes
(list, navigation tree, card, panel, chip, table, tabs, field, settings and
motion) and the canonical tokens inventory. Read packages/console-ui/index.d.ts for
the authoritative names and props.
Use Selector for searchable single-choice input, including grouped or
disabled options, async caller-owned filtering, loading/empty/error states,
validation, and explicitly enabled free-form creation. Use Select for a
small finite non-searchable list. Use the shared Tooltip parts, or
IconButton for an icon-only action; do not implement independent hover
timers, geometry, or portals. Keep a local selector only for a genuinely
different interaction such as hierarchical drill-in, multi-select, or a
persistent command palette, and document that exception.
Use TabsList variant="line"/TabsTrigger or SegmentedControl variant="tabs" for content navigation. Shared tabs add a semantic icon by
default; pass an explicit icon only when the default does not express the
view, or icon={false} only when there is a documented space constraint.
Use IconButton for icon-only actions such as Refresh or Configure so the
16 px glyph retains an accessible name and tooltip.
Configuration forms: one shared grammar
Every host.configForms implementation uses host-owned primitives. Do not
paint native inputs, selects, switches, buttons, or a private collection deck
to resemble the Console.
- Structure ordinary settings as
SettingsSection→SettingsList→SettingsField/SettingsRow. - Use
SettingsFieldfor editable values. Pass every prop supplied by itsrenderControlcallback intoInput,Select,Selector,Switch, or a domain wrapper. It generates the clickable label, description/error ARIA,data-field, and standard control width. UsecontrolSize="fit"withlayout="inline"for intrinsic controls such asSwitch. - Use
SettingsRowfor values/actions that are not a single labelled field. - Use
RawValueInputfor${ENV}templates and unknown/future scalar values. It may suggest a typed literal, but conversion happens only after the user invokesonUseLiteral. A non-string opaque value still belongs in aSettingsFieldwith an explicit conversion button so field errors remain associated with the control. - Use the shared
Selectfor finite choices andSelectorfor searchable choices. Both supportid,name, anddata-field; a nativewith worker CSS is never the fallback. - Worker CSS may arrange controls, constrain width, or apply mono to a machine-readable value. It must not override shared control color, border, radius, height, chevron, focus, disabled, or type styles.
- Size responsive controls from the pane/container rather than the viewport.
Back, section/row actions, and field controls keep at least a 44 px target in
a narrow split pane even when the desktop window itself is wide. Put
data-settings-narrow-actionon standalone empty-state actions so they use the same target rule.
For a collection whose item opens a meaningful sub-form, use SettingsDeck.
Its overview should compose Panel + List/ListItem; its detail contains
the selected item's settings. open selects exactly one level at every width:
the overview and detail are never squeezed side-by-side or stacked together.
The deck focuses the pushed heading and restores the originating row on Back.
Keep selection by a stable domain key and set it to null when the item is
removed. For a host deep link, open the requested item first, focus the exact
data-field, and temporarily disable autoFocusDetail so the heading cannot
steal that specific focus. Encode the host path as
focusField.map(String).join('.'), use that exact dotted value in
SettingsField.field, and consume each serialized request only once after its
conditional/deck content mounts. If an item can be removed, put
data-settings-deck-fallback on the surviving overview action that should
receive focus when the originating row disappears.
<SettingsField
id="redis-url"
field="adapter.config.redis_url"
label="Redis URL"
description="Connection used for distributed locks."
error={errors?.get('/adapter/config/redis_url')}
renderControl={(controlProps) => (
<Input {...controlProps} value={redisUrl} onChange={setRedisUrl} />
)}
/>
<SettingsDeck
open={activeId !== null}
title={activeItem?.label ?? 'Connection'}
backLabel="Connections"
overview={<ConnectionList onOpen={setActiveId} />}
detail={activeItem ? <ConnectionSettings item={activeItem} /> : null}
onBack={() => setActiveId(null)}
/>The host owns dirty tracking, validation, save, reset, and the SaveBar. Back is
navigation only. Update config objects surgically: preserve unknown siblings,
unknown enum/adapter payloads, and templates; display defaults without
materializing them. An opaque root is preserved just like an opaque nested
block and requires an explicit conversion; never coerce it to {} to enter
the typed form. Database is the canonical resource-deck example and Cron is
the canonical small settings-form example.
Selection is always neutral in both themes: --color-surface-selected,
--color-ink, and optionally --color-edge. Reserve --color-accent for a
primary action, form focus, live activity, or semantic domain data.
Use CardHighlight or uiClasses.cardHighlight only for related content that
needs emphasis inside an existing card. It is a borderless, shadowless inset
backed by --color-card-highlight; never use it for interaction states,
status, or as a standalone card.
Use CollapsibleCard, CollapsibleCardTrigger, and CollapsibleCardContent
for expandable cards. The shared primitive owns accessible state, auto-height
motion, reduced-motion behavior, and mounted content; workers must not copy a
private disclosure or height-animation implementation.
PageShell and PageHeader are the stable outer contract for full
pages. They keep identity, height behavior, close affordance, and header
styling consistent with the console. Use PageBody, PageSidebar, and
PageMain when their navigation/workspace model fits; replace the body with
a custom structure when the domain needs columns, a canvas, or a drill-in
flow. Do not replace the outer shell and header.
Use PageSidebar's declarative collapsible, resizable, storageKey,
width bounds, side, narrow, narrowBelow, and narrowMode props instead
of shipping local collapse DOM, drag handlers, width clamps, persistence,
focus logic, or transitions. The Console host keeps a single stable aside,
leaves children mounted while collapsed, synchronizes instances sharing a
storage key, and owns motion plus reduced-motion behavior. Pass narrow when
the page's drill-in state already knows the pane is narrow; use narrowBelow
when the shared sidebar may observe its PageBody parent.
narrowMode="inline" is the default and the required presentation for
primary navigation. It temporarily makes the sidebar a full-width mobile
navigation screen, hides collapse/resize affordances, and ignores (without
overwriting) the saved wide collapsed preference. Use it for catalogs,
list/detail pages, and hierarchies such as scopes → keys → value; preserve
each level as a distinct list, advance one level when a row is activated, and
provide a labelled Back action. The page owns that domain route, not private
sidebar mechanics. Build each level with List, optional ListGroup /
ListGroupLabel, and ListItem; pass its selected, leading, label,
description, and trailing data instead of copying row/button CSS. The
shared row owns full-width card targeting, neutral selection, keyboard
traversal, focus treatment, and mobile touch height.
Use narrowMode="drawer" only for secondary navigation that must overlay an
unchanged, still-mounted PageMain, such as a short section switcher. Do not
put a primary tree or catalog in the drawer. Neither responsive mode
overwrites the saved wide preference.
The pieces own the surface hierarchy (header on --color-panel-raised
with a hairline --color-edge border, sidebar on --color-sidebar, main
on --color-panel) — don't repaint those tokens yourself. No sidebar?
Put content straight into PageMain. Keep onRequestClose wired to
PageHeader.onClose. Keep header actions few and essential; at narrow widths,
move secondary actions into a DropdownMenu rather than allowing the header
to wrap or overflow.
When the page's worker has a configuration entry, set configurationId on
host.pages.register. The Console then places one consistent settings action
in PageHeader and opens the worker in the global Settings modal. Do not add a
second Configure action or mount WorkerConfigurationDialog; that component
exists only as a compatibility bridge for older bundles. This is the stable
form-family id: keep configurationId: 'browser' even when a runtime instance
uses III_CONFIG_NAME=browser-team-a; its configuration::register payload
must carry metadata: { ui_form: 'browser' }.
Responsive structure: pane width, not viewport width
An injected page may occupy a full tab, half of a split tab, or a narrow mobile viewport. A viewport media query cannot distinguish those cases. Observe the page body's own width (or use CSS container queries for purely visual changes) and switch the interaction model at the width where the content actually stops working.
When React must mount different narrow views, reuse the callback-ref
useContainerNarrow implementation from database, iii-directory, or
state: measure synchronously, observe with ResizeObserver, disconnect on
ref changes, and ignore zero-width hidden panes.
Apply these rules:
- Derive the threshold from the content's minimum usable width; do not copy
800or850merely because a reference uses it. - Prefer a drill-in sequence on narrow panes: list → detail, or scope → key → value. Render one primary pane at a time and provide a visible, labelled back control.
- Keep
PageSidebarin its default inline narrow mode for that sequence. Its full-width presentation is shared; do not recreate mobile rails, sheets, width overrides, or collapse state in worker CSS/JS. - Style mobile navigation as one scannable list of full-width rows/cards. Activating a row advances exactly one level; do not flatten parent and child collections into one selector or show a collapsed desktop rail first.
- Remove modes that require width. For example, collapse split edit/preview to one mode at a time.
- Make narrow interactive rows at least 44 px tall. Keep labels truncated or wrapped deliberately; never let the whole page scroll horizontally.
- Put
min-width: 0andmin-height: 0on nested flex/grid panes. Give only the content region that needs itoverflow: auto. - Use
panelSideto mirror side navigation in a wide right-hand pane. Do not mirror reading order or a single-pane narrow flow. - Key persisted UI state with
paneId(fall back totabIdon consoles without it); treatlocalStorageas best-effort. Guard dirty drafts before navigation and ignore stale async responses after the selection changes. - Keep editors mounted when hiding a preview/editor mode if cursor and scroll continuity matter. Unmount when state must reset between domain objects.
- Test keyboard focus, back navigation, reduced motion, and touch targets in addition to visual width.
Use the shared Monaco-backed CodeEditor for code or long text and
FileDiff for diffs. Put the editor inside an overflow-auto pane. Never
bundle Monaco, CodeMirror, or another editor/diff renderer into the asset.
Terminal-shaped cards (exec output, code runs, build logs) compose the shared
terminal atoms under the same rule: TerminalCommandLine for the $ command
header, TerminalStream for the labeled stdout/stderr pane (set ansi to
color it; tone="err" for stderr), and AnsiText for ANSI SGR text mapped
onto the design tokens. Never bundle an ANSI parser or carry private
terminal-rendering copies.
2. The style asset (ui/styles.css)
Plain CSS, every rule scoped under your worker's wrapper attribute:
[data-iii-ui="mywork"] .mywork-ui-main {
min-width: 0;
min-height: 0;
overflow: auto;
}
[data-iii-ui="mywork"] .mywork-ui-browser.narrow .mywork-ui-row {
min-height: 44px;
}
@keyframes mywork-flash { /* prefix keyframes names — they are global */ }The console mounts every injected render inside
Use Dark mode is a variable flip, so token-based styles theme for free. Prefer
shared components for controls, keep all UI chrome and prose in What must NOT be in the sheet: unscoped selectors ( Do not use Tailwind utility classes in injected markup: the worker's class
names are not part of the console's compiled Tailwind output. Use the named
shared components and Shared esbuild with the five shared specifiers external: Everything else gets bundled in; keep output well under the console's 8 MiB
per-asset cap (a slot component should be tens of KiB). Three footguns: The wire contract is: one content function serving all of the worker's
assets (dispatch on This registers Always register triggers through your SDK's Message path, never through
the engine's durable Node workers implement the same contract directly: register one function
that maps All registration goes through the per-script Live data pattern: a page can register its own trigger over Every injected render is error-bounded; import or setup failures remove the
extension contribution and appear in the browser console instead of breaking
the entire console. Scripts still run with full console-origin privileges;
the wrapper scopes styles but is not a security sandbox. Use the smallest override that communicates the trigger well. The base hook
keeps the generic lifecycle/delivery UI; optional hooks provide the same
compact-display and complete-detail freedom as function renderers: The host always owns the click target, accessible expanded state, animation,
renderer isolation, per-slot fallback, and the Raw JSON tab. A once firing and
automatic retirement remain one activity; never create a duplicate unbind
notice. For harness registrations, keep identity and event copy distinct: Rebuild-on-save stays in the build tool; re-registration stays in the worker.
Serve the new bytes, register a fresh trigger for the same path, then
unregister the old handle. Register-first avoids a flash; unregistering keeps
the SDK replay map bounded. The Rust helper does this with a one-second
poller. Set Every open tab hot-swaps the asset in place — scripts re- Inspect Validate all four layers; a successful esbuild run alone is not enough. For The UI is done only when:--color-bg/sidebar/panel/panel-raised/surface* for hierarchy,
--color-ink/ink-faint/ink-ghost for text, --color-alert/warn/ok and their
muted variants for status, --color-glyph-* for the tint of one 16 px
identity glyph beside a label (never a fill, border, or text),
--color-edge/rule-focus for structure,
--shadow-raised/floating/lift for elevation, and
--font-sans/--font-mono/--font-code by semantic role. Accent is not a
selected-state token.--font-sans,
and reserve --font-mono for identifiers, paths, values, payloads, and data.
Use --font-code only for source code, structured payloads, and editor text.
Never hardcode theme colors.:root, html, body,
*, bare element names) and @font-face — injected CSS is unlayered, so an
unscoped rule silently beats the console's fully-layered CSS document-wide.
The console lints every style on fetch (warn-only) and reports findings in
the manifest's warnings array; keep it empty.uiClasses recipes; add scoped worker CSS only for
domain-specific layout and data visualization. Use --motion-duration-* and
--motion-ease-* (or the shared motion recipe classes) for state changes.
Streaming text, rapidly updating meters, and cursor-following geometry update
without transitions. Scope custom selectors inside
@media (prefers-reduced-motion: reduce) too; keyframe names remain global
and must carry the worker prefix. Shared components and recipes already honor
the Console's global reduced-motion contract.Dialog, DropdownMenu, Select, Selector, Tooltip, and
BottomSheet portals preserve the worker's data-iii-ui scope
automatically. If custom domain UI portals directly to document.body, wrap
its portal root with data-iii-ui=".3. The build (
ui/build.mjs)import esbuild from 'esbuild'
const options = {
entryPoints: ['page.tsx', 'styles.css'],
bundle: true,
format: 'esm',
jsx: 'automatic',
outdir: 'dist',
external: ['react', 'react-dom', 'react-dom/client',
'react/jsx-runtime', '@iii-dev/console-ui'],
logLevel: 'info',
}
if (process.argv.includes('--watch')) {
const context = await esbuild.context(options)
await context.watch()
} else {
await esbuild.build(options)
}
react external bundles a second React — hooks resolve
against the bundled copy's never-installed dispatcher and fail at runtime
as a cryptic "Invalid hook call". (A forgotten @iii-dev/console-ui
external fails loudly instead: the package's bundleable entry throws with
the fix in the message.)react-dom/server, …) fails at import() time, not build time.CodeEditor
(above).4. Registration (the worker side)
path), one trigger per asset.Rust workers — the
iii-console-ui crateuse iii_console_ui::ConsoleUi;
ConsoleUi::new("mywork")
.script(
"mywork/page.js",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/page.js")),
)
.style(
"mywork/styles.css",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/styles.css")),
)
.register(&iii);, one Message-path trigger per asset,
and the III_ watcher. It rejects invalid paths early.
Export ui from the worker library and call ui::register(&iii) after its
normal functions. Adapt state/build.rs so missing/stale UI sources build
before include_str!; preserve unrelated duties of an existing build script.register_trigger function. Message-path triggers are
garbage-collected on disconnect and replayed on reconnect.{path} to {content, content_type?}, then one Message-path
console:script or console:style trigger per asset with
config: {path}.Runtime contract and slots
Trigger types
console:script (ESM JS), console:style (CSS); never register the tab-only console:assets type
Trigger config
{ "path": string }, nothing else
Path rules
lowercase
[a-z0-9._-] segments, no leading slash, no ./.. segments, ≤ 512 chars; extension must match the type (.js / .css); convention: first segment = your worker name — it becomes the data-iii-ui scope and the only human-readable attribution
Content function
input
{ "path": string } → output { "content": string, "content_type"?: string } (content_type defaults from the asset kind)
Size cap
8 MiB per asset — registrations over it are rejected
Reload
same path + changed content hash replaces the asset; unchanged content is a no-op
host; every entry is disposed
automatically on hot reload and worker disconnect. Each register also
returns a remover for manual teardown.host.pages.register({id, title, render}) creates #/ext/ and adds it to
the nav. Its render receives:
panelSide: 'left' | 'right' — which side of the workspace tab the
pane occupies; use it only to keep wide side navigation on the outer edge;tabId: the hosting workspace tab's stable id (tabs persist across
reloads);paneId: the hosting pane's stable id inside that tab — the same page
can be open in two columns of one tab, so key persisted UI state and
per-instance resources (terminals, live triggers) on it; fall back to
tabId when absent (older consoles);onRequestClose: close the pane hosting your page (a split drops the
column; a single pane detaches); wire it to PageHeader.onClose;workingDir: the active conversation's live working directory, or
null/absent; use only for filesystem-shaped pages and react to changes.
Surface
What it is
host.functionTriggersCustom chat/trace renderers. Match only the worker's function ids and return
null to fall through. message.description is the harness's short activity label. Set renderer metadata: { display: true } only for successful rich artifacts that should remain visible while raw details are collapsed; the hint applies to the renderer that returned the winning node. If raw data contains secrets, implement a pure, total, cycle-safe redactRaw; the raw tab and copy action otherwise expose the original input/output.
host.triggerRenderers?Layered trigger presentation. Match the inner
triggerType. tryRender supplies the source section; optional tryRenderDetails and tryRenderDisplay replace the expanded Terminal content and compact timeline content; redactRaw filters raw registration/fire values. Every slot falls through on null. Feature-detect for older consoles.
host.configFormsProvide the deliberate UI for one configuration entry in global Settings. There is no schema-generated fallback: every configurable worker must register a form. Render purpose-built fields and call
onChange; the schema validates but never generates UI, and the host retains dirty tracking, save, and reset. Use SettingsSection/SettingsList/SettingsField/SettingsRow; use SettingsDeck for collection drill-in and RawValueInput for opaque/template scalars. Never use a raw JSON textarea or restyled native controls, and honor focusField. Pass { layout: 'full' } only when the form is a workbench that owns its internal scrolling; the default contained layout keeps the centered host column.
host.providerConfigForms?Replace the form body for one exact
llm-router provider id inside the chat model picker. Use it for provider-owned OAuth, device flow, or companion-app login. The host retains the provider slice, schema validation, dirty guard, save/reset, and model refresh; the component receives { providerId, schema, value, onChange, errors, configured, available, modelCount }. Feature-detect for older consoles. Never solicit plaintext API keys here—direct operators to the provider's declared environment variable.
host.chat?Optional chat integrations: session chips, turn summaries and transcript renderers, plus
selectConversation? for explicit worker-driven navigation and composerModel? for the live model selection (including unsaved drafts). Feature-detect the namespace and each newer method.
host.iiiThe tab's bus client:
trigger(functionId, payload?, {timeoutMs?}), on(functionId, handler) (returns un-listen), registerTrigger({type, function_id, config}) (returns un-register), addConnectionStateListener, browserId. Injected UI acts by invoking its own worker's functions.
host.components / host.pathRuntime component record and the current script asset path.
host.iii
with a handler id like iii:: (the iii::
prefix keeps per-event invocations out of the trace feed). The binding is
GC'd with the tab.Give trigger renderers layered ownership
interface TriggerActivityRenderer {
id: string
isMatch(triggerType: string): boolean
tryRender(activity: TriggerActivityMessage): React.ReactNode | null
tryRenderDetails?(activity: TriggerActivityMessage): React.ReactNode | null
tryRenderDisplay?(activity: TriggerActivityMessage): React.ReactNode | null
redactRaw?(value: unknown): unknown
}TriggerActivityMessage.kind is registration, fired, or retirement;
the normalized model also carries triggerType, opaque config, optional
label and action, delivery, lifecycle, and optional payload/outcome fields.
Match triggerType, not engine::register_trigger, because many sources
share that registration function. Parse opaque worker config without throwing
and return null per slot to reach the next renderer or host fallback.
tryRender: source-specific section inside the generic detail view.tryRenderDetails: complete expanded Terminal tab. If provided, include
the lifecycle and delivery facts operators need; the host no longer adds
its generic terminal content beside it.tryRenderDisplay: compact timeline content inside the host's disclosure
button. Keep it non-interactive, one-line, and truncation-safe.redactRaw: pure, non-mutating, total, cycle-safe filtering applied before
registration/notification/fire raw panes and copy actions. A throw fails
closed to a withheld-value placeholder.{
"trigger_type": "on-message",
"config": { "scope": "explorer" },
"label": "explorer-messages",
"metadata": { "action": "new Explorer message received" }
}label names the binding. metadata.action describes what a future event
means, is available as binding data before it fires, and becomes
activity.action on durable fire records. Registration and active-binding
surfaces identify the binding with label; show action only when
activity.kind === 'fired'. The default fired row is a status mark plus action,
falling back to label, state scope/key, then source; clicking opens the detail
already expanded. Keep action short, standalone, and user-facing. It affects
presentation only, never routing or authorization.The dev loop (hot reload)
III_ for ui/dist:# terminal 1, from the workers repo root
pnpm --dir mywork/ui watch
# terminal 2; the default watch path is relative to the worker cwd
cd mywork && III_MYWORK_UI_WATCH=1 cargo runimport() +
re-setup() (React state in your slots is lost — dispose + remount), styles
link-swap with no flash. Unchanged content is hash-deduped end to end.Debugging
Symptom
Cause
Registration rejected with a path error
path violates the rules table (wrong extension, uppercase,
.., …)
Registration rejected with a fetch error
your content function threw, returned no string
content, or timed out
"Invalid hook call" in the tab
your bundle contains a second React — a missing
external
import() fails on a bare specifiera dependency imports a react-family subpath outside the five shared specifiers
Styles apply on your page but not in a custom portal
Shared portalled components preserve scope automatically; a custom
document.body portal must carry data-iii-ui=" on its root
Whole console restyled
your sheet has unscoped rules — check
warnings in the manifest
Registered but absent
inspect
workers[].enabled and injectableUi.disabledWorkers in the manifestconsole::ui-manifest (or GET ),
/ui/, registered triggers, and [iii-ui] browser logs in that order.
The manifest is authoritative; its warnings must be empty.Testing your worker's UI
pnpm --dir ; require type-check success,
non-empty assets, and no bundled React/editor copy.host.triggerRenderers, additionally cover exact type match, malformed
config fallthrough, every implemented slot and activity kind, compact
non-interactive display, complete-detail lifecycle fidelity, action fallback,
raw redaction (including thrown-error fail-closed behavior), and worker
disable/disconnect fallback.
PageShell + PageHeader are present and the close action works;configurationId, and every configuration
entry has a registered, purpose-built host.configForms interface;@iii-dev/console-ui; collection details
use SettingsDeck, preserve unknown/template data, and restore focus on Back;[iii-ui] errors.