mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
* feat(plugins): build-less TypeScript workers A worker's main may be .ts/.mts. The plugin host installs module.registerHooks scoped to the plugin folder: .ts files are transpiled with the frames' sucrase options (lines kept, inline source map, source maps on), relative imports fall back to the frames' candidate files, and a value import of the type-only 'orca/worker' fails with a clear error. Needs engines.orca >=1.4.246. * feat(plugins): components panels hot reload with their state - In dev folders a save swaps only the hidden guest: keyed memory atoms are handed to the new guest (ui.retain → ui.hydrate memory), dropped when an atom's version changes. - A save that fails to load keeps the last good render and shows an inline file:line:col error banner; the next good save clears it. A subtle Reloaded mark follows a reload. - Transpile and import errors carry line and column; dev worker restarts keep storage. * feat(plugins): orca plugin dev watch One command for the inner loop: refreshes .orca/types, dev-loads the folder, follows its frame and worker logs (plugins.getLogs gains an optional cursor), prints reloads (optional devChangeCount on list entries) and status changes, and unloads on Ctrl-C. --json streams NDJSON events for agents. * feat(skills): orca-plugin-authoring bundled skill guide A guide for agents to build Orca plugins end to end (decision table, build-less rules, the dev watch loop, test/check, validate, release-entry, approvals, gotchas) with references generated from the component catalog, host API table, event schemas, manifest schema and CLI specs, checked for drift. Fixes the components template's Reset to set(count, 0). * feat(plugins): orca plugin dev watch --check; test kit details in the authoring skill - --check runs orca plugin check after the load and after each reload (one at a time; saves made meanwhile fold into one rerun) and prints each panel's render or errors inline, as a `check` event with --json. Local folders only, since it runs the plugin's code here. - The orca-plugin-authoring guide's Test and check section now documents orca plugin test and check from #36969 exactly (JSON results, exit codes, test placement, the orca/testing kit and its common mistakes), and the loop uses dev watch --check. * refactor(plugins): move the components renderer's timing and helper constants to their own module
321 lines
21 KiB
Markdown
321 lines
21 KiB
Markdown
# 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](./plugin-isolated-frames.md).
|
||
|
||
## Manifest
|
||
|
||
```json
|
||
{
|
||
"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
|
||
|
||
```tsx
|
||
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 `orca` by name. They are JSX tags only, and lowercase tags such as
|
||
`<div>` do not exist.
|
||
- Function components take props and `children` as 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`, a `Map`, a class instance) is reported as an error and the
|
||
render is not shown.
|
||
- `key` works as in React. Give items of lists that reorder a `key`; 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](#hot-reload) 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, bump `version`; `migrate(stored, fromVersion)` turns
|
||
an older value into the new shape, and without it the atom starts from `initial`.
|
||
- `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:
|
||
|
||
```ts
|
||
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](./plugin-host-api.md)); rejections carry a `code` such as `timeout` or
|
||
`capability_denied`. `host.has(method)` says whether this Orca offers a method to the panel.
|
||
- `api` is `{ major, minor }` of the host API.
|
||
- `env.locale` is Orca's UI language, `env.theme` is `{ colorScheme }` (reading it while rendering
|
||
re-renders on a switch) and `env.client` is `'desktop'`.
|
||
- `shell.openExternal(url)` and `files.saveAs({ suggestedName, mimeType, data })` are shorthands for
|
||
`host.call('shell.openExternal' | 'files.saveAs', ...)` and resolve with the same result. They follow
|
||
the user-gesture rule below; the hidden module has no `window.orca.shell` or `window.orca.files`.
|
||
- `on(event, handler)` returns a `Disposable`. Events are typed (`OrcaEvents`); Orca's own panel messages
|
||
are not events and `on()` refuses them.
|
||
- `ui.catalog` is 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.
|
||
|
||
## Hot reload
|
||
|
||
In a development folder, saving a file reloads the panel in place:
|
||
|
||
- Keyed atoms carry over. Persisted ones reload as always; keyed `memory` atoms (`atom('draft', '')`)
|
||
are handed to the new module, so a half-typed draft or the open tab survives the save. An atom whose
|
||
`version` changed starts from `initial` (`migrate` is not called), and so does one holding something
|
||
other than plain JSON (a function, a `Map`). Unkeyed `atom(initial)` state, module variables and
|
||
`setup` start 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:column` and the message; the same line is in `orca 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](#hot-reload).
|
||
|
||
- Types are stripped and JSX compiles for `orca` (automatic runtime). Lines are kept, so an error's
|
||
`file:line` in `orca plugin logs` is the line in your source; an inline source map covers columns
|
||
in DevTools (`orca plugin dev inspect`).
|
||
- Relative imports resolve to exact files: `./todos` finds `todos.tsx`, `todos.ts`, `todos.jsx`,
|
||
`todos.js`, then `todos/index.*`, and `./todos.js` finds `todos.ts` as TypeScript expects.
|
||
- Only `orca`, `orca/*` and relative files can be imported. A bare package such as `react` fails 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 enum` across files and
|
||
`export =`, are not supported; `isolatedModules` in 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:
|
||
|
||
```json
|
||
{ "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`](../../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:
|
||
|
||
```sh
|
||
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`](../../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.
|
||
|
||
```tsx
|
||
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/testing` also exports Node's `test`, `describe`, `beforeEach`, `afterEach` and strict `assert`,
|
||
so tests type-check without `@types/node`. With vitest, use vitest's own `test` and `expect`.
|
||
- `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`, and `persisted` (`{ plugin, workspace }` by atom key; `stored(value, version)`
|
||
for a value an older version wrote, to test `migrate`). Each mount starts every atom from its
|
||
initial value; one panel is mounted at a time.
|
||
- Find with `find(query)` (the innermost match, or `null`) and `findAll(query)`. A query matches on
|
||
any of `type`, `role`, `text`, `label` (a control's `label`, a list item's or card's `title`) and
|
||
`testId` (the element's `key`); `text` and `label` match exactly or by RegExp.
|
||
- Act with `press`, `type(el, text, { replace?, submit? })`, `submit`, `toggle` and `select(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) and `persisted`. `emit('theme', ...)`, `setVisible`, `rerender` and `settled` drive the rest;
|
||
`unmount()` ends the run, running `setup` cleanup.
|
||
- `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, and `shell.openExternal` or `files.saveAs`
|
||
without a press first (each `press` grants one). `results` maps a method to a value or to a function
|
||
of its params, which may throw `hostError(code)`; `calls` and `callsOf(method)` record every call.
|
||
The stationary-control rule for gestures is not simulated.
|
||
|
||
`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:
|
||
|
||
```tsx
|
||
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.
|
||
|
||
- `defaultValue` seeds an uncontrolled field.
|
||
- `value` controls it. Once your panel has handled the person's latest edit, the `value` you 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.
|
||
- `value` without `onChange` makes the field read-only. `Switch` (`checked`/`defaultChecked`) and `Select`
|
||
(`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; Electron 43 still resolves
|
||
`dns-prefetch` hostnames), no Node, no popups, no DOM the person sees. It reaches Orca only through
|
||
`host.call`, `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.
|