diff --git a/approval-gate/Cargo.lock b/approval-gate/Cargo.lock index 6c352db16..46a733694 100644 --- a/approval-gate/Cargo.lock +++ b/approval-gate/Cargo.lock @@ -545,7 +545,7 @@ dependencies = [ [[package]] name = "harness" -version = "1.5.0" +version = "1.5.2" dependencies = [ "anyhow", "async-trait", diff --git a/console/SKILL.md b/console/SKILL.md new file mode 100644 index 000000000..2b589a41d --- /dev/null +++ b/console/SKILL.md @@ -0,0 +1,423 @@ +--- +name: console-injectable-ui +description: Build and ship worker UI (React pages, function-trigger renderers, configuration forms, stylesheets) into a running iii console at runtime — using the @iii-dev/console-ui npm package and the iii-console-ui Rust crate. Use when a worker needs its own console page, custom message rendering, or a custom config form, with hot reload and no console rebuild. +--- + +# Injectable console UI + +A worker can ship React pages, function-trigger renderers, configuration +forms, and stylesheets into every open console tab **at runtime** — no +console rebuild, no iframe, hot-reloaded on re-registration. This skill is +self-contained: everything needed to author, register, and debug injectable +UI from your own worker project is on this page. + +## How it works (one paragraph) + +The console owns three trigger types. A worker registers a `console:script` +or `console:style` trigger whose `config.path` (e.g. `mywork/page.js`) is +the asset's identity; the trigger's `function_id` names a *content function* +on the worker that the console invokes to fetch the source (`{path}` in, +`{content, content_type?}` out). The console hashes and caches the bytes, +serves them from its HTTP port (`GET /ui/?v=`), and pushes an +update to every open tab over the third type, `console:assets` (tabs +subscribe; you never register that one). Scripts are ES modules the tab +`import()`s and calls `setup(host)` on; styles are `` elements the tab +swaps in place. Re-registering the same path overrides it — that **is** the +hot-reload signal. Registration is deployment; disconnect is teardown. + +## Install + +Two packages, one per side of the wire: + +- **`@iii-dev/console-ui`** (npm) — the compile-time surface of the + console's runtime module: TypeScript types plus the component manifest. + It is types-only by design: at runtime the console's import map serves + the real module from the running SPA, so this package must stay + `external` in your build (its js entry throws to make a forgotten + external fail fast). + + ```bash + npm install --save-dev @iii-dev/console-ui + ``` + +- **`iii-console-ui`** (Rust crate) — the whole worker side for Rust + workers: registers the content function, one trigger per asset, and the + dev-loop file watcher. + + ```bash + cargo add iii-console-ui + ``` + + Match the crate and package versions to the console worker you deploy + against; the console is the runtime they both describe. (Node workers + need no worker-side package — they hand-write the two registration + pieces, shown below.) + +## Project layout + +```text +mywork/ + 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 # depends on @iii-dev/console-ui (dev) + src/ # the worker itself (Rust or Node) +``` + +## 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): + +```tsx +import { Button, EmptyState, type Host } from '@iii-dev/console-ui' + +export default function setup(host: Host) { + host.pages.register({ + id: 'mywork-manager', // page URL: #/ext/mywork-manager + title: 'mywork', // nav label + render: () => , + }) + host.functionTriggers.register(createMyTriggerRenderer(host)) + host.configForms.register('mywork', MyConfigForm) + // optional: return a teardown fn; the loader runs it on dispose +} +``` + +`Button`, `EmptyState`, `Dialog`, `Markdown`, … are the console's own +components, re-exported by name with typed props — at runtime they come from +the running console's single React tree, so importing them adds **zero +bytes** to your bundle. Use them instead of copying base components into +your worker. + +### The shared component library + +`Badge`, `Button`, `CodeEditor`, `CodeHighlight`, `Dialog` (+`DialogTrigger`, +`DialogClose`, `DialogContent`, `DialogTitle`, `DialogDescription`), +`DropdownMenu` (+`Trigger/Content/Item/Label/Separator`), `EmptyState`, +`ErrorBoundary`, `Input`, `JsonHighlight`, `Markdown`, `MarkdownPreview`, +`Select`, `Skeleton`, `StatusDot`, `StatusPanel`, `Tabs` +(+`TabsList/TabsTrigger/TabsContent`), `Tooltip` +(+`TooltipTrigger/TooltipContent`). + +**`CodeEditor` is Monaco — and it is the console's one code editor.** Every +code/text editing surface (yours included) uses it: Monaco runs once inside +the console, follows the console theme in light and dark, and grows with its +content (put it inside an `overflow-auto` pane). Never bundle +`monaco-editor`, CodeMirror, or any other editor into a worker asset — it +would ship megabytes toward the per-asset size cap to duplicate what the +console already provides. + +```tsx +import { CodeEditor } from '@iii-dev/console-ui' + + +``` + +## 2. The style asset (`ui/styles.css`) + +Plain CSS, **every rule scoped under your worker's wrapper attribute**: + +```css +[data-iii-ui="mywork"] .mywork-ui { color: var(--color-ink); } +@keyframes mywork-flash { /* prefix keyframes names — they are global */ } +``` + +The console mounts every injected render inside +`
`, so +scoped rules apply to your UI and nothing else. Use the console's design +tokens — `--color-bg`, `--color-ink`, `--color-ink-faint`, +`--color-ink-ghost`, `--color-accent`, `--color-accent-fg`, `--color-alert`, +`--color-ok`, `--color-warn`, `--color-panel`, `--color-paper-2`, +`--color-ring`, `--color-rule`, `--color-rule-2` (also exported as `tokens` +from `@iii-dev/console-ui`) — dark mode is a variable flip, so token-based +styles theme for free. Never hardcode theme colors. + +What must NOT be in the sheet: unscoped selectors (`: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. + +## 3. The build (`ui/build.mjs`) + +esbuild with the five shared specifiers external: + +```js +import { build } from 'esbuild' + +await build({ + 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'], +}) +``` + +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: + +- **A forgotten `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.) +- **Only those five specifiers exist in the import map.** A transitive + dependency importing any other bare react-family specifier + (`react-dom/server`, …) fails at `import()` time, not build time. +- **Never bundle an editor** — use the shared Monaco-backed `CodeEditor` + (above). + +## 4. Registration (the worker side) + +The wire contract is: one content function serving all of the worker's +assets (dispatch on `path`), one trigger per asset. + +### Rust workers — the `iii-console-ui` crate + +```toml +# Cargo.toml +[dependencies] +iii-console-ui = "0.1" +``` + +```rust +use 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 call registers everything: the content function (`::ui-content`, +flagged `internal: true` so it stays console plumbing rather than +discoverable API), one Message-path trigger per asset (MIME type derived +from the asset kind), and the `III__UI_WATCH` dev watcher. Each +default derives from the worker name and has a builder override +(`.content_function_id(…)`, `.watch_env(…)`, `.watch_default_dir(…)`). The +builder panics on paths the console would reject (wrong extension, +uppercase, `..` segments, duplicates) — an authoring mistake fails your +first unit test instead of warn-logging against a running engine; trigger +registration failures at runtime stay warn-logged, not fatal. Embedding +`dist/` with `include_str!` (rebuilt from `build.rs`) keeps the worker one +self-contained binary. + +### Node workers — write the two pieces directly + +```ts +iii.registerFunction('mywork::ui-content', async ({ path }) => { + const file = ASSETS[path] // { 'mywork/page.js': 'dist/page.js', … } + if (!file) throw new Error(`unknown ui asset: ${path}`) + return { content: await readFile(file, 'utf8') } +}) +const trigger = iii.registerTrigger({ + type: 'console:script', + function_id: 'mywork::ui-content', + config: { path: 'mywork/page.js' }, +}) +``` + +**Always register triggers through your SDK's Message path, never through +the engine's durable `register_trigger` function.** Function-path triggers +outlive your worker (a page pointing at a dead content function) and +silently vanish on engine restart with no replayer. Message-path triggers +are GC'd on disconnect and replayed by the SDK on reconnect — injected UI +dies and revives with its worker, which is the design. + +Ordering never matters: register before the console is up and the engine +parks the intent, delivering it when the console arrives; console restarts +replay every live binding; engine restarts are absorbed by SDK +re-registration. + +## The wire contract (what the console enforces) + +| | | +|---|---| +| Trigger types | `console:script` (ESM JS), `console:style` (CSS), `console:assets` (tab subscriptions — you don't register these) | +| 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 | +| Fetch budget | 2 attempts × 3 s at live registration (a failed fetch **rejects the registration**; the error reaches your SDK's registration result); 3 × 5 s on console-restart replay (failure drops the asset until you re-register) | +| Override | same path re-registered ⇒ last writer wins, console-wide (even across workers); the superseded engine row is pruned | +| Identity/versioning | content hash (first 16 hex of sha256) — unchanged content re-registered is a no-op | +| Per-worker toggle | a worker listed in the console configuration's `injectableUi.disabledWorkers` has its registrations **accepted but held**: no serve, no manifest row, no tab pushes. Toggling back on pushes everything held to every open tab. The `console` worker itself cannot be disabled. | + +## What `setup(host)` can register + +All registration goes through the per-script `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 })` + +A whole console page at `#/ext/`, listed in the nav while registered. +Duplicate ids: last registration wins. + +### `host.functionTriggers.register(renderer)` + +Custom rendering for function-trigger messages in chat and traces: + +```ts +interface FunctionTriggerRenderer { + id: string + isMatch(functionId: string): boolean + tryRender(message: FunctionTriggerMessage): React.ReactNode | null + tryRenderRunning?(message: FunctionTriggerMessage): React.ReactNode | null + tryRenderPreview?(message: FunctionTriggerMessage): React.ReactNode | null + FunctionIdLabel?: React.ComponentType<{ functionId: string }> +} +``` + +Injected renderers dispatch **before** the console's first-party families, +so you can override built-in rendering for your worker's functions. Return +`null` to fall through — match narrowly (your own function ids) and let +errors and everything else keep the default cards. Renderer callbacks are +fenced: a throwing `isMatch` counts as no-match, a throwing `tryRender` +degrades to an error chip, never a broken feed. + +### `host.configForms.register(configurationId, component)` + +Replace the schema-generated form for one configuration entry on the Workers +tab (exact id match; last registration wins). Your component receives: + +```ts +interface ConfigFormProps { + id: string + schema: Record | null // null = value registered without a schema + value: JsonValue + onChange(next: JsonValue): void // propose the full next value + errors?: ReadonlyMap // JSON-pointer → message + focusField?: readonly string[] // deep-link focus request — honoring it is your job +} +``` + +The form is render-level only: dirty tracking, validation, save/reset stay +host-owned. You draw the fields and call `onChange`. + +### The rest of `host` + +| Surface | What it is | +|---|---| +| `host.iii` | The 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` | The shared component library as an untyped record (same objects as the named exports). | +| `host.useTheme()` | `'light' \| 'dark'`, reactive. Extensions follow the theme, never set it. | +| `host.path` | Your script's asset path. | + +Live data pattern: a page can register its *own* trigger over `host.iii` +with a handler id like `iii::-ui::events::` (the `iii::` +prefix keeps per-event invocations out of the trace feed). The binding is +GC'd with the tab. + +### Containment + +Every injected render is wrapped in the scope element plus an error +boundary: a render-time crash degrades to a chip naming your script, never a +white screen. A failed `import()`, missing default export, or throwing +`setup()` logs to the browser console and your contributions simply drop out +until the next good version arrives — a broken extension never takes the +console down. + +## The dev loop (hot reload) + +Rebuild-on-save stays in your build tool; **re-registration stays in the +worker process** (a Message-path trigger dies with whatever connection +registered it). The re-register discipline, per changed asset: + +```text +1. build tool rewrites dist/ +2. worker swaps the bytes it serves, registers a FRESH trigger for the same path +3. THEN unregisters the previous handle +``` + +Register-first avoids a zero-trigger window (a flash-dispose in tabs). The +trailing `unregister()` is contract, not tidiness: the console prunes +superseded rows from the engine, but your SDK's local replay map only +shrinks via `unregister()` — skip it and every reconnect replays your entire +rebuild history (harmless but churny). + +For Rust workers the `iii-console-ui` crate implements exactly this as a +1 s content poller, armed by the watch env var — `III__UI_WATCH`, +set to `1` for `ui/dist` or to an explicit build-output directory: + +```bash +cd mywork/ui && npm run watch # esbuild --watch → dist/ +III_MYWORK_UI_WATCH=1 cargo run # poll ui/dist/, re-register on change +``` + +Every open tab hot-swaps the asset in place — scripts re-`import()` + +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. + +Preview trick: any throwaway process can register a `console:script` +trigger for a *preview* path (e.g. `mywork/page-preview.js`) serving +experimental bytes — disconnect GCs it. Same-content re-registration is a +no-op by hash; vary the content to force a push. + +## Debugging + +| Surface | What you get | +|---|---| +| `console::ui-manifest` (function) or `GET :3113/ui` | `{ disabled, assets: [{ path, kind, hash, worker, warnings }], workers: [{ worker, enabled, assets }] }` — `assets` is the authoritative loadable set; `disabled: true` means the kill switch is on | +| `GET :3113/ui/` | the served bytes (`Cache-Control: no-cache`, `ETag: ""`) | +| `engine::registered-triggers::list { trigger_type: "console:script" }` | the engine's view: trigger ids, config, worker attribution | +| Browser console | `[iii-ui] …` loader logs: import failures, cleanup throws, stylesheet load failures | + +Common failures: + +| 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 specifier | a dependency imports a react-family subpath outside the five shared specifiers | +| Styles apply on your page but not in a portal you created | DOM you portal to `document.body` must carry `data-iii-ui=""` on its root | +| Whole console restyled | your sheet has unscoped rules — check `warnings` in the manifest | +| Asset gone after console restart | replay fetch failed (worker down at replay) — re-register, or restart the worker | +| Registered without errors but never loads | the worker is toggled off — check `workers[].enabled` in the manifest / `injectableUi.disabledWorkers` in the `console` configuration entry | + +Kill switch: `injectable_ui: false` in the console worker's configuration +disables the trigger types, the `/ui` + `/vendor` routes, and the loader +(manifest answers `disabled: true`). + +## Testing your worker's UI + +- Assert the embedded build outputs: nonempty ESM for scripts, and that + every CSS rule is scoped under your `data-iii-ui` attribute (build tools + may print the selector unquoted: `[data-iii-ui=mywork]`). +- e2e smoke without a browser: boot engine + console + your worker; assert + `console::ui-manifest` lists your paths with non-empty hashes and empty + `warnings`; `GET /ui/` returns your bytes; re-register with changed + content and assert the hash moved. +- Rendering correctness stays a browser concern — Storybook in your UI + project against the `@iii-dev/console-ui` types is the recommended + harness. + +## Security posture (know what you're shipping) + +Injected scripts run with full console-origin privileges — deliberately the +same trust level as any worker on the bus, which can already invoke any +function. There is no sandbox and the `data-iii-ui` wrapper is styling +hygiene, not isolation. Hardened deployments gate trigger-type registration +via RBAC; the console's `/ws` proxy already drops browser-originated +trigger-type registrations. diff --git a/console/web/package.json b/console/web/package.json index 89935c7c8..84fe7af95 100644 --- a/console/web/package.json +++ b/console/web/package.json @@ -39,6 +39,7 @@ "iii-browser-sdk": "^0.21.6", "lexical": "^0.44.0", "lucide-react": "^1.16.0", + "monaco-editor": "^0.56.0", "prism-react-renderer": "^2.4.1", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/console/web/public/vendor/console-ui.js b/console/web/public/vendor/console-ui.js index d297e7d3a..2d37b9855 100644 --- a/console/web/public/vendor/console-ui.js +++ b/console/web/public/vendor/console-ui.js @@ -9,6 +9,7 @@ export const tokens = api.tokens export const { Badge, Button, + CodeEditor, CodeHighlight, Dialog, DialogClose, @@ -27,6 +28,7 @@ export const { Input, JsonHighlight, Markdown, + MarkdownPreview, Select, Skeleton, StatusDot, diff --git a/console/web/src/components/chat/directory/__tests__/parsers.test.ts b/console/web/src/components/chat/directory/__tests__/parsers.test.ts deleted file mode 100644 index 750ffd930..000000000 --- a/console/web/src/components/chat/directory/__tests__/parsers.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - DIRECTORY_FUNCTION_IDS, - isDirectoryFunction, - promptsGetResponseSchema, - promptsListResponseSchema, - registryWorkerInfoRequestSchema, - registryWorkerInfoResponseSchema, - registryWorkersListRequestSchema, - registryWorkersListResponseSchema, - safeParseRequest, - safeParseResponse, - skillsDownloadRequestSchema, - skillsDownloadResponseSchema, - skillsGetRequestSchema, - skillsGetResponseSchema, - skillsIndexResponseSchema, - skillsListRequestSchema, - skillsListResponseSchema, - unwrapEnvelope, -} from '../parsers' - -function wrap(details: T) { - return { - content: [{ type: 'text', text: JSON.stringify(details) }], - details, - terminate: false, - } -} - -describe('isDirectoryFunction', () => { - it('matches every id in the allowlist', () => { - for (const id of DIRECTORY_FUNCTION_IDS) { - expect(isDirectoryFunction(id)).toBe(true) - } - }) - - it('rejects unrelated ids', () => { - expect(isDirectoryFunction('directory::')).toBe(false) - expect(isDirectoryFunction('directory::skills')).toBe(false) - expect(isDirectoryFunction('engine::workers::list')).toBe(false) - }) -}) - -describe('directory::skills::list', () => { - it('parses an empty request', () => { - expect(safeParseRequest(skillsListRequestSchema, {})).toEqual({}) - }) - - it('parses a prefix+type filter', () => { - expect( - safeParseRequest(skillsListRequestSchema, { - prefix: 'sandbox/', - type: 'how-to', - include_description: false, - }), - ).toEqual({ - prefix: 'sandbox/', - type: 'how-to', - include_description: false, - }) - }) - - it('parses a wrapped response payload', () => { - const payload = { - skills: [ - { - id: 'sandbox/skills/sandbox/create', - title: 'sandbox::create', - type: 'how-to', - function_id: 'sandbox::create', - description: '…', - bytes: 1840, - modified_at: '2026-05-26T10:00:00Z', - }, - ], - } - const parsed = safeParseResponse(skillsListResponseSchema, wrap(payload)) - expect(parsed?.skills[0].function_id).toBe('sandbox::create') - }) - - it('parses an entry with null type + function_id', () => { - expect( - safeParseResponse(skillsListResponseSchema, { - skills: [ - { - id: 'sandbox/index', - title: 'sandbox', - type: null, - function_id: null, - description: '', - bytes: 100, - modified_at: '', - }, - ], - }), - ).toBeTruthy() - }) -}) - -describe('directory::skills::get', () => { - it('parses the request payload', () => { - expect(safeParseRequest(skillsGetRequestSchema, { id: 'a/b' })).toEqual({ - id: 'a/b', - }) - }) - - it('parses a wrapped get response', () => { - const parsed = safeParseResponse(skillsGetResponseSchema, { - id: 'a/b', - title: 'A', - type: 'how-to', - function_id: null, - body: '# A', - modified_at: '2026-05-26T10:00:00Z', - }) - expect(parsed?.body).toBe('# A') - }) - - it('rejects a response missing body', () => { - expect( - safeParseResponse(skillsGetResponseSchema, { - id: 'x', - title: 't', - modified_at: '', - }), - ).toBeNull() - }) -}) - -describe('directory::skills::index', () => { - it('parses the wrapped response', () => { - const parsed = safeParseResponse( - skillsIndexResponseSchema, - wrap({ body: '## a', workers_count: 1 }), - ) - expect(parsed?.workers_count).toBe(1) - }) -}) - -describe('directory::skills::download', () => { - it('parses both source variants in the request', () => { - expect( - safeParseRequest(skillsDownloadRequestSchema, { - repo: 'https://x', - skill: 'a', - branch: 'main', - }), - ).toBeTruthy() - expect( - safeParseRequest(skillsDownloadRequestSchema, { - worker: 'pdfkit', - version: '1.0.0', - }), - ).toBeTruthy() - }) - - it('parses the wrapped response', () => { - const parsed = safeParseResponse(skillsDownloadResponseSchema, { - namespace: 'sandbox', - skills_written: ['sandbox/index'], - prompts_written: [], - source: { kind: 'repo' }, - }) - expect(parsed?.namespace).toBe('sandbox') - }) -}) - -describe('directory::prompts::list / get', () => { - it('parses the list payload', () => { - expect( - safeParseResponse(promptsListResponseSchema, wrap({ prompts: [] })), - ).toEqual({ prompts: [] }) - }) - - it('parses the get payload', () => { - const parsed = safeParseResponse(promptsGetResponseSchema, { - name: 'a', - description: 'b', - body: '# a', - modified_at: '', - }) - expect(parsed?.body).toBe('# a') - }) -}) - -describe('directory::registry::workers::list', () => { - it('parses an empty request', () => { - expect(safeParseRequest(registryWorkersListRequestSchema, {})).toEqual({}) - }) - - it('parses a search+cursor request', () => { - expect( - safeParseRequest(registryWorkersListRequestSchema, { - search: 'pdf', - cursor: 'abc', - }), - ).toEqual({ search: 'pdf', cursor: 'abc' }) - }) - - it('parses a wrapped page with workers + pagination', () => { - const parsed = safeParseResponse( - registryWorkersListResponseSchema, - wrap({ - workers: [ - { - name: 'pdfkit', - description: 'pdf renderer', - type: 'binary', - version: '1.0.0', - total_downloads: 4823, - author: { name: 'iii', verified: true }, - }, - ], - pagination: { - next_cursor: 'xyz', - has_more: true, - page_size: 20, - }, - }), - ) - expect(parsed?.workers[0].author?.verified).toBe(true) - expect(parsed?.pagination.has_more).toBe(true) - }) -}) - -describe('directory::registry::workers::info', () => { - it('parses the request shape', () => { - expect( - safeParseRequest(registryWorkerInfoRequestSchema, { - name: 'pdfkit', - tag: 'latest', - }), - ).toEqual({ name: 'pdfkit', tag: 'latest' }) - }) - - it('parses a wrapped detail response with api reference + skills tree', () => { - const parsed = safeParseResponse( - registryWorkerInfoResponseSchema, - wrap({ - worker: { - name: 'pdfkit', - description: 'pdf renderer', - type: 'binary', - version: '1.0.0', - }, - readme: '# pdfkit', - api_reference: { - functions: [ - { name: 'pdfkit::render', description: 'render html→pdf' }, - ], - triggers: [], - }, - skills_tree: { - skills: [{ path: 'pdfkit/index' }], - prompts: [], - }, - }), - ) - expect(parsed?.api_reference.functions).toHaveLength(1) - expect(parsed?.skills_tree.skills).toHaveLength(1) - }) - - it('rejects a response missing worker', () => { - expect( - safeParseResponse(registryWorkerInfoResponseSchema, { - api_reference: { functions: [], triggers: [] }, - skills_tree: { skills: [], prompts: [] }, - }), - ).toBeNull() - }) -}) - -describe('unwrapEnvelope re-export', () => { - it('peels the harness envelope', () => { - const inner = { skills: [] } - expect(unwrapEnvelope(wrap(inner))).toEqual(inner) - }) -}) diff --git a/console/web/src/components/chat/directory/index.tsx b/console/web/src/components/chat/directory/index.tsx deleted file mode 100644 index d4b5c710a..000000000 --- a/console/web/src/components/chat/directory/index.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { SandboxErrorView } from '@/components/chat/sandbox/ErrorView' -import { parseSandboxErrorDisplay } from '@/components/chat/sandbox/parsers' -import type { FunctionTriggerMessage } from '@/types/chat' -import { SkillsDownloadView } from './DownloadView' -import { PromptsGetView, PromptsListView } from './PromptsViews' -import { isDirectoryFunction, unwrapEnvelope } from './parsers' -import { - RegistryWorkerInfoView, - RegistryWorkersListView, -} from './RegistryViews' -import { SkillsGetView, SkillsIndexView, SkillsListView } from './SkillsViews' - -export function DirectoryFunctionIdLabel({ - functionId, -}: { - functionId: string -}) { - if (!functionId.startsWith('directory::')) { - return {functionId} - } - const tail = functionId.slice('directory::'.length) - return ( - <> - directory:: - {tail} - - ) -} - -function tryRender(message: FunctionTriggerMessage): React.ReactNode | null { - if (!isDirectoryFunction(message.functionId)) return null - if (message.pendingApproval) return null - - const input = unwrapEnvelope(message.input) - const rawOutput = message.output - const output = rawOutput != null ? unwrapEnvelope(rawOutput) : undefined - const running = !!message.running - - // Reuse the sandbox error parser for the shared `function_error` - // / gate-denial envelope — same translate layer. - const errorDisplay = - !running && rawOutput != null ? parseSandboxErrorDisplay(rawOutput) : null - if (errorDisplay) { - return - } - - switch (message.functionId) { - case 'directory::skills::list': - return - case 'directory::skills::get': - return - case 'directory::skills::index': - return - case 'directory::skills::download': - return ( - - ) - case 'directory::prompts::list': - return - case 'directory::prompts::get': - return - case 'directory::registry::workers::list': - return ( - - ) - case 'directory::registry::workers::info': - return ( - - ) - default: - return null - } -} - -/** - * Directory reads are non-destructive and don't go through the approval - * gate. `download` could in principle (it touches disk), but the daemon - * doesn't gate it today — returning `null` lets the default request JSON - * pane handle the pending-state if it ever surfaces. - */ -function tryRenderPreview( - _message: FunctionTriggerMessage, -): React.ReactNode | null { - return null -} - -export const DirectoryToolView = { - isDirectoryFunction, - tryRender, - tryRenderRunning: tryRender, - tryRenderPreview, -} diff --git a/console/web/src/components/chat/directory/shared.tsx b/console/web/src/components/chat/directory/shared.tsx deleted file mode 100644 index ca491196a..000000000 --- a/console/web/src/components/chat/directory/shared.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import type { ReactNode } from 'react' -import { Chip } from '@/components/chat/sandbox/shared' -import { Markdown } from '@/lib/markdown' - -interface KvChipProps { - label: string - children: ReactNode -} - -/** Two-tone chip with a small uppercase label and a value. Reused across - * every directory view for filter + metadata badges. */ -export function KvChip({ label, children }: KvChipProps) { - return ( - - - {label} - - {children} - - ) -} - -/** - * Render a markdown body (skill `.md`, prompt body, worker README) as actual - * markdown — headings, fenced code, lists — via the same `` renderer - * the chat transcript uses, rather than the raw monospace `
` it used to be.
- */
-export function MarkdownPane({
-  body,
-  className,
-}: {
-  body: string
-  className?: string
-}) {
-  return (
-    
- {body} -
- ) -} - -export function formatRelativeTime(value: string): string { - if (!value) return '' - const ts = Date.parse(value) - if (Number.isNaN(ts)) return value - const diffMs = Date.now() - ts - if (diffMs < 0) return new Date(ts).toLocaleString() - if (diffMs < 60_000) return `${Math.floor(diffMs / 1000)}s ago` - if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago` - if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago` - if (diffMs < 7 * 86_400_000) return `${Math.floor(diffMs / 86_400_000)}d ago` - return new Date(ts).toLocaleDateString() -} - -export function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes}B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` - return `${(bytes / (1024 * 1024)).toFixed(1)}MB` -} diff --git a/console/web/src/components/function-trigger/FunctionTriggerCard.tsx b/console/web/src/components/function-trigger/FunctionTriggerCard.tsx index d31bf0239..11c2efdf7 100644 --- a/console/web/src/components/function-trigger/FunctionTriggerCard.tsx +++ b/console/web/src/components/function-trigger/FunctionTriggerCard.tsx @@ -195,7 +195,7 @@ function formatPrimitive(v: Primitive): string { /** * Branch the function-id label across the renderer registry — injected - * renderers first, then the 13 first-party families. The default + * renderers first, then the first-party families. The default * (unbranded) span keeps unknown ids readable. */ function FunctionIdLabel({ functionId }: { functionId: string }) { diff --git a/console/web/src/components/function-trigger/renderer-registry.tsx b/console/web/src/components/function-trigger/renderer-registry.tsx index eb5526a42..4afcfee27 100644 --- a/console/web/src/components/function-trigger/renderer-registry.tsx +++ b/console/web/src/components/function-trigger/renderer-registry.tsx @@ -1,6 +1,6 @@ /** * Ordered renderer registry for `FunctionTriggerCard` — replaces the - * hand-chained `??` dispatch across the 13 first-party ToolView families, + * hand-chained `??` dispatch across the first-party ToolView families, * and prepends runtime-injected renderers (injectable UI's * `host.functionTriggers.register`). * @@ -15,10 +15,6 @@ import { BrowserToolView, } from '@/components/chat/browser' import { CoderFunctionIdLabel, CoderToolView } from '@/components/chat/coder' -import { - DirectoryFunctionIdLabel, - DirectoryToolView, -} from '@/components/chat/directory' import { EngineFunctionIdLabel, EngineToolView } from '@/components/chat/engine' import { FpFunctionIdLabel, FpToolView } from '@/components/chat/fp' import { @@ -48,7 +44,7 @@ import type { FunctionTriggerMessage } from '@/types/chat' import type { FunctionTriggerRenderer } from '@/types/injectable-ui' /** - * The 13 first-party families, in the exact order of the old `??` chains. + * The first-party families (12 since directory moved into its worker's injected UI), in the exact order of the old `??` chains. * Each family's `tryRender*` already gates on its own function ids, so an * entry returning `null` falls through to the next. */ @@ -69,14 +65,8 @@ export const FIRST_PARTY_RENDERERS: readonly FunctionTriggerRenderer[] = [ tryRenderPreview: EngineToolView.tryRenderPreview, FunctionIdLabel: EngineFunctionIdLabel, }, - { - id: 'first-party/directory', - isMatch: DirectoryToolView.isDirectoryFunction, - tryRender: DirectoryToolView.tryRender, - tryRenderRunning: DirectoryToolView.tryRenderRunning, - tryRenderPreview: DirectoryToolView.tryRenderPreview, - FunctionIdLabel: DirectoryFunctionIdLabel, - }, + // directory::* rendering is no longer first-party: the iii-directory + // worker ships it as injected UI (iii-directory/ui/src/function-trigger). { id: 'first-party/worker', isMatch: WorkerToolView.isWorkerFunction, diff --git a/console/web/src/components/ui/CodeEditor.tsx b/console/web/src/components/ui/CodeEditor.tsx new file mode 100644 index 000000000..136380409 --- /dev/null +++ b/console/web/src/components/ui/CodeEditor.tsx @@ -0,0 +1,272 @@ +import type * as monacoNs from 'monaco-editor' +import * as React from 'react' +import { cn } from '@/lib/utils' + +export interface CodeEditorHandle { + focus(): void +} + +export interface CodeEditorProps { + value: string + onChange: (next: string) => void + /** Monaco language id (`'markdown'`, `'json'`, `'yaml'`, …). Unknown + ids degrade to plain text with the same chrome. */ + language: string + /** Class for the outer wrapper (borders, min-height, width). */ + className?: string + placeholder?: string + /** Read-only: content stays selectable/copyable, chrome unchanged. */ + readOnly?: boolean + /** Inert and dimmed (implies read-only). */ + disabled?: boolean + autoFocus?: boolean + id?: string + 'aria-label'?: string + /** Observes keys bubbling out of the editor (shortcuts like ⌘S) — keys + Monaco consumes for editing never reach it. */ + onKeyDown?: React.KeyboardEventHandler +} + +/* The pre-Monaco fallback (and permanent degraded mode if the editor chunk + ever fails to load) renders the same typography the editor is configured + with, so the swap-in doesn't reflow the text. */ +const EDITOR_TYPOGRAPHY = + 'm-0 whitespace-pre-wrap break-words px-3 py-2 text-left font-mono text-[12.5px] leading-[19px]' + +const MONACO_OPTIONS: monacoNs.editor.IStandaloneEditorConstructionOptions = { + automaticLayout: true, + wordWrap: 'on', + minimap: { enabled: false }, + lineNumbers: 'off', + folding: false, + glyphMargin: false, + lineDecorationsWidth: 12, + lineNumbersMinChars: 0, + renderLineHighlight: 'none', + scrollBeyondLastLine: false, + scrollbar: { + // The wrapper grows with content (see fitHeight) — the OUTER pane owns + // vertical scrolling, exactly like the old textarea editor. + vertical: 'hidden', + horizontal: 'hidden', + alwaysConsumeMouseWheel: false, + useShadows: false, + }, + overviewRulerLanes: 0, + hideCursorInOverviewRuler: true, + guides: { indentation: false }, + occurrencesHighlight: 'off', + selectionHighlight: false, + fontSize: 12.5, + lineHeight: 19, + padding: { top: 8, bottom: 8 }, + // Suggest/hover widgets escape ancestor overflow clipping. + fixedOverflowWidgets: true, + // Prose-friendly: no word-based popups while typing; language services + // (JSON schema completions, …) still fire on trigger characters / ⌃Space. + quickSuggestions: false, + wordBasedSuggestions: 'off', + contextmenu: false, +} + +/** + * The console's code editor — Monaco, themed by the design tokens (the + * `iii-console` theme in `lib/monaco.ts` follows `html[data-theme]`), shared + * with injected worker UI through `@iii-dev/console-ui`. Every code/text + * editing surface goes through this component; nothing else in the console + * (or in a worker asset) may instantiate its own editor. + * + * The wrapper grows with content and does NOT scroll itself — put it inside + * an `overflow-auto` pane. Monaco loads lazily in its own chunk; until it + * arrives (or if it never does) a plain textarea with identical typography + * keeps the surface editable. + */ +export const CodeEditor = React.forwardRef( + ( + { + value, + onChange, + language, + className, + placeholder, + readOnly, + disabled, + autoFocus, + id, + 'aria-label': ariaLabel, + onKeyDown, + }, + ref, + ) => { + const hostRef = React.useRef(null) + const fallbackRef = React.useRef(null) + const editorRef = React.useRef(null) + const applyingRef = React.useRef(false) + const [ready, setReady] = React.useState(false) + + // The mount effect runs once; it reads mount-time props through here. + const latest = React.useRef({ value, language, autoFocus }) + latest.current = { value, language, autoFocus } + const onChangeRef = React.useRef(onChange) + onChangeRef.current = onChange + + React.useImperativeHandle(ref, () => ({ + focus: () => { + if (editorRef.current) editorRef.current.focus() + else fallbackRef.current?.focus() + }, + })) + + React.useEffect(() => { + let disposed = false + void import('@/lib/monaco') + .then(({ monaco, CONSOLE_THEME, monoFontFamily }) => { + if (disposed || !hostRef.current) return + const editor = monaco.editor.create(hostRef.current, { + ...MONACO_OPTIONS, + value: latest.current.value, + language: latest.current.language, + theme: CONSOLE_THEME, + fontFamily: monoFontFamily(), + }) + editorRef.current = editor + editor.getModel()?.updateOptions({ tabSize: 2, insertSpaces: true }) + + const fitHeight = () => { + if (hostRef.current) + hostRef.current.style.height = `${editor.getContentHeight()}px` + } + editor.onDidChangeModelContent(() => { + if (applyingRef.current) return + // Read through the ref-captured editor: the latest onChange is + // re-bound below on every render via this stable dispatcher. + onChangeRef.current(editor.getValue()) + }) + editor.onDidContentSizeChange(fitHeight) + fitHeight() + if (latest.current.autoFocus) editor.focus() + setReady(true) + }) + .catch((err) => { + console.warn( + '[console] monaco failed to load — staying on the plain fallback editor', + err, + ) + }) + return () => { + disposed = true + editorRef.current?.dispose() + editorRef.current = null + } + }, []) + + // Prop → editor sync (external value swaps, language, options). + React.useEffect(() => { + const editor = editorRef.current + if (!ready || !editor) return + if (editor.getValue() !== value) { + applyingRef.current = true + editor.setValue(value) + applyingRef.current = false + } + }, [ready, value]) + + React.useEffect(() => { + const editor = editorRef.current + const model = editor?.getModel() + if (!ready || !editor || !model) return + void import('@/lib/monaco').then(({ monaco }) => { + if (editorRef.current === editor && editor.getModel() === model) + monaco.editor.setModelLanguage(model, language) + }) + }, [ready, language]) + + React.useEffect(() => { + if (!ready) return + const editor = editorRef.current + if (!editor) return + editor.updateOptions({ + readOnly: !!(readOnly || disabled), + domReadOnly: !!(readOnly || disabled), + placeholder, + ariaLabel, + }) + // The wrapper's `inert` already blurs in browsers that support it; + // this is the explicit fallback so a disabled editor never keeps a + // hidden focused textarea. + if (disabled && (editor.hasTextFocus() || editor.hasWidgetFocus())) { + const active = document.activeElement + if (active instanceof HTMLElement) active.blur() + } + }, [ready, readOnly, disabled, placeholder, ariaLabel]) + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: shortcut relay + gap-click focus around a real editor +