* feat(plugins): panels talk to their own worker, read storage, settings and events
- plugin.request (host API 1.2): a panel or editor asks its plugin's worker, which answers with
orca.panels.handle(name); the worker pushes with orca.panels.post. Same plugin only, JSON
checked, 1 MiB answers, 256 KiB/200-per-10s posts, a deadline and the panel's lifetime.
- Panels and editors get storage.*, settings.* and secrets.* behind the same capabilities, and
storage/settings onDidChange on every surface; events.subscribe delivers workspace and agent
events to panels that hold the read capability.
- contributes.settings: typed settings (a JSON Schema subset) with defaults and value checks.
- Frames get orca.plugin/storage/settings/secrets; components panels import plugin, storage,
settings, secrets from 'orca', and atom(key, init, { persist: 'storage' }) shares a value with
the worker. Workers get orca.panels/storage/settings/secrets sugar.
- One typed contract: declare module 'orca' { interface PluginRequests / PluginPanelEvents }.
- Desktop push channel per open panel session (plugins:openPanelPush).
* feat(plugins): Orca Settings shows and edits a plugin's contributes.settings
The plugin page lists declared settings with Orca's own controls (switch, picker, text and
number fields, Reset to default). Edits are checked against the declaration in main, written to
the plugin's settings, and reach open panels and the running worker through onDidChange.
* docs(plugins): hello-tsx talks to its worker; authoring guide covers panels, workers and data
The example now uses one of each: plugin.request answered by orca.panels.handle, a worker post
heard with plugin.on, a persist: 'storage' atom the worker watches, two contributes.settings and
agent.status.changed in the panel, all typed by one src/protocol.ts. The build-less TSX e2e
drives the whole loop.
* fix(plugins): typed bridge overloads, panel call budget, settings IPC and catalog keys
- PluginBridgeApi/PanelsApi use overloads: declared names are typed (and complete), any other
name carries JSON; no never-unions for the type-aware lint.
- The per-plugin panel call budget is 120 per 10 s, since a panel loading storage atoms,
settings and its first requests makes a burst; the hostile fixture floods past it.
- Settings IPC builds its host per call; the settings section's strings are in en.json.
* test(plugins): the example's declared settings show under the plugin with the person's value
* refactor(plugins): keep hello-tsx the minimal starter; the bridge demo is hello-worker-panel
hello-tsx goes back to a sandboxed-ui panel with no worker or capabilities. The worker, protocol,
storage atom, settings and events move to examples/plugins/hello-worker-panel, with their own
e2e (plugin-worker-panel) and PR route.
* feat(plugins): test kit covers the panel bridge and plugin data
mockHost keeps the plugin's storage, settings and secrets in memory (seeded with storage,
settings, secrets; read with host.data()) and fakes the worker with requests, so plugin.request,
storage/settings/secrets and persist: 'storage' atoms work under mount(), writes reach
onDidChange, and panel.post(event, payload) is what plugin.on hears. Typed by PluginRequests and
PluginPanelEvents.
* test(plugins): focusInGuest waits for the guest and retries focusing as one step
Under load the custom-editor guest could still be attaching, so executeJavaScript threw
'dom-ready' before the focus poll began. Wait for waitForGuestReady, then retry focusing the
webview and the element together, tolerating a guest that is still attaching or remounts.
* docs(plugins): the authoring skill covers panels, workers and data; persistence leaves the renderer
- skill-guides/orca-plugin-authoring.md gains Panels, workers and data (plugin.request/on,
orca.panels.handle/post, persist: 'storage', contributes.settings, panel events, test kit), and
its generated references read orca-services.d.ts for shell, files, on and the bridge types.
- After the dev-loop rebase, persisted-atom wiring (queue plus storage sync) moves to
createDeclarativePersistence so the renderer stays under max-lines.
* feat(plugins): panels show agents: agents.list, terminal ids on agent events, workspace.changed
- agents.list (agent:read, every surface): the focused workspace's agents with terminalId,
title, agent kind, state and stateStartedAt, from the hook server's store.
- agent.status.changed carries terminalId and title: paneKey mapped through the runtime's
terminal list (tabId:leafId) or the status's terminal handle; looked up only while a plugin
listens, in arrival order.
- workspace.readContext terminals carry title; workspace.changed (workspace:read) fires after
workspace session writes when the focused workspace or its terminals change.
- Event subscriptions are kept per panel session, so one made before the push channel opens
still counts.
- shell.openExternal never prompts on a hidden or minimized window: it settles
{ opened: false, reason: 'consent-unavailable' } and says so in the plugin log, which also
notes 'waiting for the person to confirm' while a prompt is up; the rate budget is spent only
when Orca acts. e2e launches never open a real browser or mail client.
- A worker may export activate as default or by name; docs, typings and the authoring skill
document the entry, the agent events and a live agent status recipe.
* fix(plugins): the plugin page names the workspace.changed subscription
* fix(plugins): workers activate only by default export, and say so when it is named
Orca calls the worker module's default export. A module with a named `activate` and no default
now fails to start with an error naming the fix (`export default function activate(orca)`),
instead of being called by a fallback; docs, typings and the skill say default export.
hello-worker-panel gains a picker setting (suggestionOrder) the worker reads.
22 KiB
Components panels (TSX)
A components panel describes its UI as data; Orca draws it with its own controls, so it matches the app in light and dark, follows its keyboard and focus rules, and needs no HTML or CSS. Design contract: plugin-isolated-frames.md.
Manifest
{
"execution": "sandboxed-ui",
"engines": { "orca": ">=1.4.246" },
"contributes": {
"panels": [{ "id": "todos", "title": "Todos", "entry": "src/panel.tsx", "ui": "components" }]
}
}
ui defaults to html (a web page in an isolated frame). With components, entry is an ES module:
.tsx, .ts, .jsx or .mts with no build step, or a bundled .js/.mjs. ui and build-less
entries arrived in Orca 1.4.246, so declare at least that in engines.orca; an older Orca then says
which release the plugin needs.
Writing a panel
import { atom, read, set, update, Stack, Text, Button } from 'orca'
const count = atom('count', 0, { persist: 'plugin' })
export default function Panel() {
return (
<Stack gap="sm">
<Text>Clicked {read(count)} times</Text>
<Button onPress={() => update(count, (n) => n + 1)}>Click</Button>
<Button variant="ghost" onPress={() => set(count, 0)}>
Reset
</Button>
</Stack>
)
}
- The default export must be a component function; it is mounted as the panel.
mount(Component)does the same explicitly. - Catalog components are imported from
orcaby name. They are JSX tags only, and lowercase tags such as<div>do not exist. - Function components take props and
childrenas in React. There are no hooks: keep state in atoms. - Props are plain JSON data (strings, finite numbers, booleans, null, arrays, plain objects) or event
handler functions. Anything else (a
Date, aMap, a class instance) is reported as an error and the render is not shown. keyworks as in React. Give items of lists that reorder akey; siblings must not share one.
State
atom(initial)holds state for this panel run.atom(key, initial, { persist, version, migrate })is named, unique per plugin, and may persist:persist: 'memory'(default; this panel run, plus hot reloads in development),'plugin'(every run of the plugin) or'workspace'(per Orca workspace; switching workspaces swaps the values). Persisted values must be plain JSON, load before the first render, are saved shortly after they change, and live in the plugin's own data folder; no capability is needed. When the stored shape changes, bumpversion;migrate(stored, fromVersion)turns an older value into the new shape, and without it the atom starts frominitial.persist: 'storage'keeps the atom in the plugin's own storage under its key (thestorage:owncapability): the worker reads the same value withorca.storage.get(key), and a write from the worker or another panel updates the atom. Values are stored as they are, soversionandmigratedo not apply; atoms the module creates on load have their value before the first render.derived(() => ...)computes from other atoms; it is read-only.read(atom)reads; reading while rendering subscribes the panel to it.set(atom, value)stores a value as is, functions included;update(atom, (previous) => next)derives the next one. Both are batched (about one render per frame) and throw while rendering.
Lifetime
The module runs while its panel is the open right-sidebar tab, including while the sidebar is collapsed or Orca's window is hidden. Switching to another tab, closing the panel, disabling the plugin or reloading it ends the run; only persisted atoms carry over to the next one.
An optional setup export runs once, after persisted atoms load and before the first render:
import { on, read, type PanelContext } from 'orca'
export function setup(ctx: PanelContext) {
ctx.subscriptions.push(on('theme', ({ colorScheme }) => console.log(colorScheme)))
// ctx.signal aborts, and ctx.subscriptions are disposed, when the run ends.
// read(ctx.visible) is true while the person can see the panel.
return () => {
// Also runs when the run ends.
}
}
Cleanup runs when the panel reloads or navigates; when a panel is closed its process ends with it, so do not rely on cleanup to save anything: persist it in an atom.
Talking to Orca
The orca module has the same facade as frame panels:
host.call(method, params, { signal, timeoutMs }), typed from the host API (see plugin-host-api.md); rejections carry acodesuch astimeoutorcapability_denied.host.has(method)says whether this Orca offers a method to the panel.apiis{ major, minor }of the host API.env.localeis Orca's UI language,env.themeis{ colorScheme }(reading it while rendering re-renders on a switch) andenv.clientis'desktop'.shell.openExternal(url)andfiles.saveAs({ suggestedName, mimeType, data })are shorthands forhost.call('shell.openExternal' | 'files.saveAs', ...)and resolve with the same result. They follow the user-gesture rule below; the hidden module has nowindow.orca.shellorwindow.orca.files.on(event, handler)returns aDisposable. Events are typed (OrcaEvents); Orca's own panel messages are not events andon()refuses them.ui.catalogis a number that grows with the component catalog;ui.has(component, prop?)says whether this Orca draws a component or accepts a prop on it.plugin.request(name, input)andplugin.on(event)talk to the plugin's own worker;storage,settingsandsecretsare the plugin's own data;on('agent.status.changed')and the workspace events arrive with their read capability. See Panels, workers and data.
Hot reload
In a development folder, saving a file reloads the panel in place:
- Keyed atoms carry over. Persisted ones reload as always; keyed
memoryatoms (atom('draft', '')) are handed to the new module, so a half-typed draft or the open tab survives the save. An atom whoseversionchanged starts frominitial(migrateis not called), and so does one holding something other than plain JSON (a function, aMap). Unkeyedatom(initial)state, module variables andsetupstart over;setup's cleanup runs for the old module. - A brief Reloaded mark shows in the panel's corner.
- A save that fails to load (a syntax error, an import Orca refuses, or a module that throws before
its first render) keeps the last version that loaded on screen, with an inline error naming
file:line:columnand the message; the same line is inorca plugin logs. Until the next save loads, that last tree does not respond to clicks. The next save that loads replaces it and clears the error. Lines are your source's; a column can be off on a line that had types or JSX.
Installed plugins remount from scratch on an update; only persisted atoms carry over.
No build step
Orca transpiles .tsx, .ts, .jsx and .mts files when the panel loads, so a plugin ships its
source and no dist/ folder: edit, save, and the panel hot reloads.
- Types are stripped and JSX compiles for
orca(automatic runtime). Lines are kept, so an error'sfile:lineinorca plugin logsis the line in your source; an inline source map covers columns in DevTools (orca plugin dev inspect). - Relative imports resolve to exact files:
./todosfindstodos.tsx,todos.ts,todos.jsx,todos.js, thentodos/index.*, and./todos.jsfindstodos.tsas TypeScript expects. - Only
orca,orca/*and relative files can be imported. A bare package such asreactfails with an error naming the file and the import: copy the package's ESM files into the plugin and import them by relative path, or bundle (below). - Transpiling is syntax only: it never type-checks. Each source file is capped at 1 MiB and 2 s of transpiling, which runs off Orca's main thread.
- TypeScript features that need type information to compile, such as
const enumacross files andexport =, are not supported;isolatedModulesin the generated tsconfig flags them.
Types: orca plugin types . writes Orca's typings and a tsconfig to .orca/types/ (orca plugin dev load refreshes them), and .orca/ ignores itself in Git. Extend it:
{ "extends": "./.orca/types/tsconfig.json", "include": ["src"] }
Then npx tsc -p . type-checks. orca plugin create <dir> --template components scaffolds all of
this. examples/plugins/hello-tsx is a complete sample.
Bundling instead
A bundled .js entry still works, for example to use npm packages. orca is provided by Orca at
runtime, so leave it out of the bundle:
esbuild src/panel.tsx --bundle --format=esm --jsx=automatic --jsx-import-source=orca \
--external:orca --external:'orca/*' --outfile=dist/panel.js
/** @jsx h */ with import { h, Fragment } from 'orca' (classic jsxFactory) also works when you
bundle. examples/plugins/hello-components has a build
script.
Testing
orca plugin test [dir] runs the plugin's *.test.ts(x) files in plain Node, with no Orca window:
Node's built-in runner (node:test), or the plugin's own vitest when it has one installed. orca and
orca/testing resolve to the files this Orca ships, and the plugin's sources are transpiled and
resolved exactly as Orca loads them, so a test runs the code Orca runs. The exit code is the runner's.
import { assert, mount, test } from 'orca/testing'
import * as panel from './panel'
test('adds a task', async () => {
const view = await mount(panel, { persisted: { plugin: { todos: [] } } })
await view.type({ label: 'New task' }, 'Ship it', { submit: true })
assert.ok(view.find({ role: 'button', text: /Ship it/ }))
assert.deepEqual(view.persisted.plugin.todos, [{ id: 1, title: 'Ship it', done: false }])
assert.deepEqual(view.errors, [])
await view.unmount()
})
orca/testingalso exports Node'stest,describe,beforeEach,afterEachand strictassert, so tests type-check without@types/node. With vitest, use vitest's owntestandexpect.mount(module | Component, options)boots the panel with the real runtime (persisted atoms,setup, then the first render) and resolves once it has drawn. Options:host,theme,locale,visible,panelId, andpersisted({ plugin, workspace }by atom key;stored(value, version)for a value an older version wrote, to testmigrate). Each mount starts every atom from its initial value; one panel is mounted at a time.- Find with
find(query)(the innermost match, ornull) andfindAll(query). A query matches on any oftype,role,text,label(a control'slabel, a list item's or card'stitle) andtestId(the element'skey);textandlabelmatch exactly or by RegExp. - Act with
press,type(el, text, { replace?, submit? }),submit,toggleandselect(el, value), on an element or a query. Each act resolves once the panel has settled: its renders, its host calls and its persisted writes.valueOf(el)is what Orca shows in an Input, Switch or Select, with the same edit protocol as Orca. - Inspect
tree()(plain data for snapshots, handlers shown as[handler]),errors(what the panel threw, with its stack, and renders Orca would refuse),warnings(props and components Orca would drop) andpersisted(plugin,workspace, and the plugin'sstorage).emit(event, payload)deliversthemeor a workspace or agent event,post(event, payload)is the worker'sorca.panels.post(whatplugin.onhears), andsetVisible,rerenderandsettleddrive the rest;unmount()ends the run, runningsetupcleanup. mockHost({ capabilities, results })stands in for Orca's host. It refuses what Orca refuses, with the same error codes: a method panels cannot call, a capability the manifest does not declare (capabilities default to the manifest's), bad params, andshell.openExternalorfiles.saveAswithout a press first (eachpressgrants one).resultsmaps a method to a value or to a function of its params, which may throwhostError(code);callsandcallsOf(method)record every call. The stationary-control rule for gestures is not simulated.- The mock host keeps the plugin's storage, settings and secrets in memory (seed them with
mockHost({ storage, settings, secrets }), read them withhost.data()), sostorage,settings,secretsandpersist: 'storage'atoms work undermount(), and a write reachesonDidChangeas in Orca.requestsstands in for the worker: eachplugin.requestname maps to its answer or a function of the input (which may throwhostError(code)); an unlisted name isunknown_method. Both follow yourPluginRequeststypes.
orca plugin check [dir] validates the plugin like orca plugin validate, then renders every
components panel once against mockHost() and reports what the panel threw (with file:line),
layouts Orca would refuse, and props it would drop. Both commands run the plugin's code on your
machine with your full access, outside Orca's sandbox, so run them only on code you trust. They use
the node on PATH when it is 20.6 or newer (the first with module.register), else the Node built
into Orca, so authors need no Node install.
Catalog
Props are semantic: tones, sizes and gaps, never class names, styles or colours. Colour has one
vocabulary, tone: neutral accent success warning danger.
| Component | Props | Children |
|---|---|---|
Stack |
gap (none xs sm md lg), align (start center end stretch) |
any |
Row |
gap, align, justify (start center end between), wrap |
any |
Text |
tone, muted, size (xs sm md), weight (regular medium semibold), truncate |
text, Text Link Code Badge |
Heading |
level (1–3, below the panel title) |
as Text |
Button |
variant (solid outline ghost), tone, size, icon, disabled, onPress() |
text |
IconButton |
icon and label (required; the label is its name and tooltip), variant, tone, size, disabled, onPress() |
none |
Badge |
tone |
text |
Card |
title, description |
any |
List |
label |
ListItem only |
ListItem |
title, description, icon, current, disabled, onPress() |
any; no controls with onPress |
Input |
label (required), labelHidden, value, defaultValue, placeholder, type (text search password), maxLength, invalid, disabled, onChange(value), onSubmit(value) |
none |
Switch |
label (required), checked, defaultChecked, disabled, onChange(checked) |
none |
Select |
label (required), labelHidden, value, defaultValue, placeholder, options ({ value, label, disabled? }[]), disabled, onChange(value) |
none |
Separator |
orientation (horizontal vertical) |
none |
Progress |
label (required), labelHidden, value (0–100) |
none |
Spinner |
label (required), labelHidden |
none |
EmptyState |
title (required), description, icon |
actions |
Link |
href (https:, http:, mailto:; opened in the OS browser), onPress() |
text |
Code |
block |
text |
labelHidden keeps the label as the control's accessible name without showing it.
Icons are the same names a manifest panel's icon takes, for example add delete edit
refresh search settings check close info warning error open-external copy download
play stop more filter branch file folder terminal agent link rocket database; see
IconName in the typings. Any other name shows a neutral fallback glyph.
Forward compatibility: an unknown prop or enum value is dropped (Orca logs a warning once), and an
unknown component, or one missing a required prop, is drawn as a neutral placeholder, so a panel built for a
newer Orca still shows the rest of its UI. Only structural problems (a ListItem outside a List, a
Stack inside Text, duplicate keys, controls inside a pressable ListItem) and the limits below refuse a
render.
Import only the components your minimum supported Orca exports. orca is a real ES module, so a named
import it lacks (import { Carousel } from 'orca' on an older Orca) is a link error: the whole panel
module fails to load, rather than getting undefined. For a component newer than your minimum, check
ui.has() and create it by name with h; an Orca that does not know it draws the placeholder:
import { h, ui, Text } from 'orca'
const gallery = ui.has('Carousel') ? (
h('Carousel', { label: 'Shots' })
) : (
<Text muted>Update Orca to see the gallery.</Text>
)
Inputs
Orca owns what the person types, so a re-render never drops characters and Enter submits exactly what is on
screen. onChange(value) reports each edit; onSubmit(value) fires on Enter.
defaultValueseeds an uncontrolled field.valuecontrols it. Once your panel has handled the person's latest edit, thevalueyou render is what the field shows, including a reset to the same value it had before (''after submit). A render that your panel made before handling their latest keystrokes does not touch their text.valuewithoutonChangemakes the field read-only.Switch(checked/defaultChecked) andSelect(value/defaultValue) work the same way.
A handler that throws (or rejects) is reported in a banner attributed to your plugin; the panel keeps its last good render.
Limits
A render is refused, and the last good one kept, beyond 5000 nodes, depth 64, about 512 KiB of data,
16 KiB per text, 1 KiB per label prop, 32 props per node or 500 Select options.
User gestures
host.call('shell.openExternal' | 'files.saveAs') needs a user gesture, and only a press on Orca's own
controls counts (the hidden module never has one of its own). A press on a Button, IconButton,
ListItem or Link grants one call within 1 s, but only when that control has been stationary for 500 ms:
the same place in the tree, the same label and target, and the same position on screen. Anything that
moves it (including a sibling growing above it) restarts the wait, so a panel cannot slide a control under
the pointer just before a click. Scrolling the panel does not count as a move.
A Link follows the same rule before anything happens: a press on an unsettled Link neither opens its
href nor runs its onPress. When a Link has both, the href open uses the press, and onPress runs
without granting a second gesture.
Isolation
The module runs in a hidden, sandboxed guest of the plugin's own partition, with the same isolation as frame
panels: no network (requests outside the plugin's own files are cancelled), no Node, no popups, no DOM
the person sees. It reaches Orca only through host.call (which plugin, storage, settings and
secrets use), on, and the UI it renders. Components panels need the desktop app: browser clients
(orca serve in a web browser) show that instead of the panel.