From 831d71e6d8b0a8e2d81e235873a00ba707215acc Mon Sep 17 00:00:00 2001 From: Sergio Marcelino Date: Thu, 13 Aug 2026 07:42:08 -0300 Subject: [PATCH 1/5] feat: more console improvements --- browser/src/functions/mod.rs | 3 +- browser/ui/page.tsx | 9 +- .../ui/src/function-trigger-message/index.tsx | 34 + console/README.md | 7 +- console/SKILL.md | 728 +++---- .../web/docs/custom-function-components.md | 118 +- console/web/src/App.tsx | 8 + .../chat/FunctionTriggerMessage.stories.tsx | 102 + .../function-trigger/FunctionTriggerCard.tsx | 163 +- .../function-trigger/redact-raw.test.tsx | 67 + .../function-trigger/renderer-registry.tsx | 23 + console/web/src/components/ui/CodeEditor.tsx | 6 +- console/web/src/components/ui/FileDiff.tsx | 3 +- console/web/src/hooks/use-workspace-tabs.ts | 13 + console/web/src/index.css | 70 +- console/web/src/lib/code-font.test.tsx | 50 + console/web/src/lib/console-api.ts | 3 + console/web/src/lib/markdown.tsx | 6 +- console/web/src/lib/monaco.ts | 12 +- console/web/src/lib/panel-context.test.ts | 33 + console/web/src/lib/panel-context.ts | 73 + .../web/src/lib/sessions/entry-mapper.test.ts | 7 +- console/web/src/lib/sessions/entry-mapper.ts | 23 +- console/web/src/lib/syntax.tsx | 8 +- console/web/src/lib/ui-loader.tsx | 9 +- console/web/src/lib/ui-slots.ts | 3 + console/web/src/lib/workspace-tabs.test.ts | 89 + console/web/src/lib/workspace-tabs.ts | 73 + .../tabs/WorkersTab/WorkerEditor.tsx | 28 +- console/web/src/pages/Ext/index.tsx | 3 + .../components/WorkerConfigurationDialog.tsx | 15 +- console/web/src/types/chat.ts | 6 + console/web/src/types/injectable-ui.ts | 48 + docs/sops/injectable-console-ui.md | 61 +- harness/README.md | 2 +- harness/prompts/default.txt | 19 +- harness/prompts/subagent.txt | 9 +- harness/src/policy.rs | 37 +- packages/console-ui/README.md | 20 + packages/console-ui/index.d.ts | 48 + pnpm-lock.yaml | 32 + pnpm-workspace.yaml | 2 + shell/src/code/change_journal.rs | 154 ++ shell/src/code/functions/create_file.rs | 50 +- shell/src/code/functions/delete_file.rs | 55 +- shell/src/code/functions/mod.rs | 30 +- shell/src/code/functions/update_file.rs | 40 +- shell/src/code/mod.rs | 1 + shell/src/code/state.rs | 2 + shell/src/configuration.rs | 1 + shell/tests/code_lifecycle.rs | 67 +- shell/ui/page.tsx | 4 + .../src/function-trigger/FileChangesCard.tsx | 131 ++ .../src/function-trigger/FileChangesView.tsx | 86 + .../__tests__/file-changes.test.ts | 162 ++ shell/ui/src/function-trigger/file-changes.ts | 202 ++ shell/ui/src/page/ChangeDiffPane.tsx | 100 + .../src/page/__tests__/panel-context.test.ts | 32 + shell/ui/src/page/index.tsx | 93 +- shell/ui/src/page/panel-context.ts | 39 + shell/ui/styles.css | 223 ++ storage/Cargo.lock | 46 +- storage/Cargo.toml | 14 +- storage/README.md | 141 +- storage/build.rs | 75 + storage/config.collect.yaml | 14 +- storage/config.yaml | 12 +- storage/config.yaml.example | 3 + storage/iii.worker.yaml | 2 +- storage/src/backend/factory.rs | 23 +- storage/src/backend/gcs.rs | 44 +- storage/src/backend/local.rs | 1920 ++++++++++++++--- storage/src/backend/mod.rs | 92 +- storage/src/backend/s3.rs | 57 +- storage/src/config.rs | 290 +-- storage/src/configuration.rs | 498 ++++- storage/src/error.rs | 21 +- storage/src/handlers/delete_object.rs | 6 +- storage/src/handlers/get_object.rs | 12 +- storage/src/handlers/head_object.rs | 9 +- storage/src/handlers/list_buckets.rs | 32 + storage/src/handlers/list_objects.rs | 89 + storage/src/handlers/mod.rs | 29 +- storage/src/handlers/presign_post.rs | 77 + storage/src/handlers/presign_url.rs | 11 +- storage/src/handlers/put_object.rs | 17 +- storage/src/lib.rs | 2 +- storage/src/main.rs | 363 +--- storage/src/manifest.rs | 2 +- storage/src/rustfs/health.rs | 31 - storage/src/rustfs/mod.rs | 6 - storage/src/rustfs/spawn.rs | 253 --- storage/src/triggers/handler.rs | 22 +- storage/src/triggers/normalize.rs | 101 - storage/src/triggers/pollers/mod.rs | 1 - .../src/triggers/pollers/rustfs_webhook.rs | 135 -- storage/src/ui.rs | 50 + storage/tests/e2e/config.all.yaml | 4 +- storage/tests/e2e/config.yaml | 4 +- storage/tests/e2e/local.rs | 172 +- storage/tests/e2e/mod.rs | 2 +- storage/tests/e2e/run-tests.sh | 171 +- storage/tests/e2e/script-tests/run.sh | 18 +- .../tests/e2e/workers/harness/iii.worker.yaml | 2 +- .../tests/e2e/workers/harness/package.json | 2 +- .../e2e/workers/harness/src/cases-edge.ts | 16 +- .../e2e/workers/harness/src/cases-triggers.ts | 4 +- .../tests/e2e/workers/harness/src/runner.ts | 10 +- .../tests/fixtures/events/rustfs_created.json | 16 - .../tests/fixtures/events/rustfs_deleted.json | 12 - storage/tests/integration.rs | 24 +- storage/ui/build.mjs | 24 + storage/ui/package.json | 18 + storage/ui/page.tsx | 12 + storage/ui/src/configuration/index.tsx | 991 +++++++++ storage/ui/src/page.tsx | 593 +++++ storage/ui/src/widgets.tsx | 150 ++ storage/ui/styles.css | 1214 +++++++++++ storage/ui/tsconfig.json | 14 + web/Cargo.lock | 13 + web/Cargo.toml | 1 + web/build.rs | 119 + web/src/functions/fetch.rs | 1 + web/src/lib.rs | 1 + web/src/main.rs | 8 +- web/src/ui.rs | 50 + web/ui/build.mjs | 24 + web/ui/package.json | 18 + web/ui/page.tsx | 6 + web/ui/src/function-trigger.tsx | 107 + web/ui/styles.css | 44 + web/ui/tsconfig.json | 14 + 132 files changed, 9399 insertions(+), 2525 deletions(-) create mode 100644 console/web/src/lib/code-font.test.tsx create mode 100644 console/web/src/lib/panel-context.test.ts create mode 100644 console/web/src/lib/panel-context.ts create mode 100644 shell/src/code/change_journal.rs create mode 100644 shell/ui/src/function-trigger/FileChangesCard.tsx create mode 100644 shell/ui/src/function-trigger/FileChangesView.tsx create mode 100644 shell/ui/src/function-trigger/__tests__/file-changes.test.ts create mode 100644 shell/ui/src/function-trigger/file-changes.ts create mode 100644 shell/ui/src/page/ChangeDiffPane.tsx create mode 100644 shell/ui/src/page/__tests__/panel-context.test.ts create mode 100644 shell/ui/src/page/panel-context.ts create mode 100644 storage/src/handlers/list_buckets.rs create mode 100644 storage/src/handlers/list_objects.rs create mode 100644 storage/src/handlers/presign_post.rs delete mode 100644 storage/src/rustfs/health.rs delete mode 100644 storage/src/rustfs/mod.rs delete mode 100644 storage/src/rustfs/spawn.rs delete mode 100644 storage/src/triggers/pollers/rustfs_webhook.rs create mode 100644 storage/src/ui.rs delete mode 100644 storage/tests/fixtures/events/rustfs_created.json delete mode 100644 storage/tests/fixtures/events/rustfs_deleted.json create mode 100644 storage/ui/build.mjs create mode 100644 storage/ui/package.json create mode 100644 storage/ui/page.tsx create mode 100644 storage/ui/src/configuration/index.tsx create mode 100644 storage/ui/src/page.tsx create mode 100644 storage/ui/src/widgets.tsx create mode 100644 storage/ui/styles.css create mode 100644 storage/ui/tsconfig.json create mode 100644 web/src/ui.rs create mode 100644 web/ui/build.mjs create mode 100644 web/ui/package.json create mode 100644 web/ui/page.tsx create mode 100644 web/ui/src/function-trigger.tsx create mode 100644 web/ui/styles.css create mode 100644 web/ui/tsconfig.json diff --git a/browser/src/functions/mod.rs b/browser/src/functions/mod.rs index df555fc19..7e130b68c 100644 --- a/browser/src/functions/mod.rs +++ b/browser/src/functions/mod.rs @@ -660,7 +660,8 @@ fn register_screenshot(iii: &Arc, sessions: &Arc) { }) } }) - .description(SCREENSHOT_DESC), + .description(SCREENSHOT_DESC) + .metadata(json!({ "display": true })), ); } diff --git a/browser/ui/page.tsx b/browser/ui/page.tsx index 8d5a4bb21..7d4dc0352 100644 --- a/browser/ui/page.tsx +++ b/browser/ui/page.tsx @@ -20,7 +20,10 @@ */ import type { Host } from '@iii-dev/console-ui' -import { createBrowserRenderer } from './src/function-trigger-message' +import { + createBrowserRenderer, + createBrowserScreenshotRenderer, +} from './src/function-trigger-message' import { BrowserPage } from './src/page' export default function setup(host: Host) { @@ -30,5 +33,9 @@ export default function setup(host: Host) { render: (props) => , }) + // A captured page is a first-class chat artifact. Register its focused + // renderer first; the general browser renderer still owns errors/running + // states and every other browser::* function. + host.functionTriggers.register(createBrowserScreenshotRenderer()) host.functionTriggers.register(createBrowserRenderer(host)) } diff --git a/browser/ui/src/function-trigger-message/index.tsx b/browser/ui/src/function-trigger-message/index.tsx index d405de7fe..a458c528e 100644 --- a/browser/ui/src/function-trigger-message/index.tsx +++ b/browser/ui/src/function-trigger-message/index.tsx @@ -80,6 +80,23 @@ function ScreenshotBody({ output }: { output: unknown }) { ) } +function renderScreenshot( + message: FunctionTriggerMessage, +): React.ReactNode | null { + if ( + message.functionId !== 'browser::screenshot' || + message.pendingApproval || + message.running || + message.output == null + ) { + return null + } + if (parseInfraErrorDisplay(message.output)) return null + const screenshot = parseScreenshotOutput(message.output) + if (!screenshot?.dataUrl) return null + return +} + /** * Per-function pretty body; null when the function is unknown or its * payload doesn't parse, in which case the caller falls back to the @@ -195,3 +212,20 @@ export function createBrowserRenderer(_host: Host): FunctionTriggerRenderer { FunctionIdLabel, } } + +/** + * Focused renderer that promotes successful screenshots into the chat flow. + * Keeping it separate means other browser calls retain the compact card and + * a malformed/error response safely falls through to the general renderer. + */ +export function createBrowserScreenshotRenderer(): FunctionTriggerRenderer { + return { + id: 'browser/page.js#screenshot-display', + isMatch: (functionId) => functionId === 'browser::screenshot', + tryRender: renderScreenshot, + tryRenderRunning: () => null, + tryRenderPreview: () => null, + FunctionIdLabel, + metadata: { display: true }, + } +} diff --git a/console/README.md b/console/README.md index b7dcdd364..e4e4fe03a 100644 --- a/console/README.md +++ b/console/README.md @@ -235,8 +235,11 @@ disposes the old module and re-imports the new one. Injected scripts default- export `setup(host)` and register through `host.pages` (whole pages at `#/ext/`), `host.functionTriggers` (function-trigger message renderers — injected renderers dispatch before the built-in families, so matching a -built-in id overrides it), and `host.configForms` (replace the workers-tab -form region for one configuration id; dirty/save/reset stay host-owned). +built-in id overrides it; `metadata.display` promotes the winning renderer's +rich result into the collapsed chat flow), and `host.configForms` (replace the workers-tab +form region for one configuration id; dirty/save/reset stay host-owned). A +configuration form can opt into `{ layout: 'full' }` to receive the entire +available editor width and height; contained layout remains the default. Renders are fenced by an ErrorBoundary and scoped under `data-iii-ui=""`. `console::ui-manifest` (internal) lists the loadable assets. The `state` worker's `ui/` directory is the working diff --git a/console/SKILL.md b/console/SKILL.md index d7d061ec1..44aad38fd 100644 --- a/console/SKILL.md +++ b/console/SKILL.md @@ -1,70 +1,160 @@ --- 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. +description: Build, structure, and validate polished responsive worker UI (React pages, function-trigger renderers, configuration forms, and stylesheets) injected into the running iii console. Use when adding or changing a worker's console UI, especially when it must match the visual quality of database, console functions/triggers, iii-directory, and state; retain the shared header and visual system; work in narrow/mobile-sized panes; preserve state safely; hot reload; and ship without rebuilding the console. --- # 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.) +A worker can ship pages, 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: + +1. Add `/ui` to the root `pnpm-workspace.yaml`. +2. Add the compile-time UI surface to `/ui/package.json`: + + ```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" } + } + ``` + +3. For a Rust worker, link the worker-side registration helper in + `/Cargo.toml`: + + ```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 ```text 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 # depends on @iii-dev/console-ui (dev) - src/ # the worker itself (Rust or Node) + 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.css ``` +Start 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 + +1. Read `packages/console-ui/index.d.ts`; never guess a component or prop. +2. Select only the needed slots, then model the primary object, navigation, + actions, async states, and state that must survive navigation or reload. +3. Design wide and narrow flows deliberately; do not squeeze desktop UI. +4. 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 | `console/web/src/components/ui/PageChrome.tsx` | `PageShell`, `PageHeader`, surface roles | +| Catalog/detail | `console/ui/src/catalog/widgets.tsx`, `console/ui/src/catalog/FunctionsPage.tsx`, `console/ui/src/catalog/TriggersPage.tsx`, `console/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. +- 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. Use uppercase tracking only for short labels. +- Use sans for explanation and prose; use mono for ids, paths, schemas, + counts, code, data, and compact technical chrome. Do not make all text mono. +- Repeat one restrained identity glyph in the list row, empty hero, and + detail masthead, as console functions/triggers do. Use simple stroke SVGs, + not 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 text, and a 2 px accent rail—never color alone. +- 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 `Tabs` for peer views of the same object and a segmented control for + mutually exclusive work modes. Do not use tabs as decoration or expose an + empty mode. +- Use compact fact sheets or stat tiles only for useful comparisons. Prefer a + quiet `--color-surface` group 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`, and + `StatusPanel`; never present raw error text as the main design. +- For tables, keep the header sticky, numbers aligned/tabular, rows hoverable, + selection distinct, and horizontal overflow inside the grid. For related + detail, use an inspector or context rail and stack it below before it crowds + the primary work. + +### 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 @@ -74,135 +164,152 @@ 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' +import { + type Host, + PageHeader, + PageMain, + type PageRenderProps, + PageShell, +} from '@iii-dev/console-ui' + +function MyworkPage({ + host, + onRequestClose, +}: PageRenderProps & { host: Host }) { + return ( + + } + title="mywork" + description={host.path} + onClose={onRequestClose} + /> + + {/* Compose the chosen archetype here. */} + + + ) +} export default function setup(host: Host) { host.pages.register({ id: 'mywork-manager', // page URL: #/ext/mywork-manager title: 'mywork', // nav label - render: () => , + render: (props) => , }) - host.functionTriggers.register(createMyTriggerRenderer(host)) - host.configForms.register('mywork', MyConfigForm) - // optional: return a teardown fn; the loader runs it on dispose + + // Register other slots only when their implementations exist. + // host.functionTriggers.register(createMyTriggerRenderer(host)) + // host.configForms.register('mywork', MyConfigForm) } ``` -`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. +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 -`AnsiText`, `Badge`, `Button`, `CodeEditor`, `CodeHighlight`, `Dialog` -(+`DialogTrigger`, `DialogClose`, `DialogContent`, `DialogTitle`, -`DialogDescription`), `DropdownMenu` (+`Trigger/Content/Item/Label/Separator`), -`EmptyState`, `ErrorBoundary`, `FileDiff`, `Input`, `JsonHighlight`, -`Markdown`, `MarkdownPreview`, `PageShell`/`PageHeader`/`PageBody`/ -`PageSidebar`/`PageMain` (the page chrome — see below), `Select`, `Skeleton`, -`StatusDot`, `StatusPanel`, `Tabs` (+`TabsList/TabsTrigger/TabsContent`), -`TerminalCommandLine`, `TerminalStream`, `Tooltip` -(+`TooltipTrigger/TooltipContent`). +The package exports page chrome, buttons, inputs, select, tabs, dialogs, +menus, tooltips, badges, status/empty/loading components, Markdown and JSON +renderers, the terminal atoms (`AnsiText`, `TerminalStream`, +`TerminalCommandLine`), `CodeEditor`, `FileDiff`, and +`WorkerConfigurationDialog`. Read `packages/console-ui/index.d.ts` for the +authoritative names and props. -**The page chrome is the mandatory layout for pages.** Every registered -page composes the same five pieces, so your pane looks exactly like the -console's own screens (chat, traces) and every other worker's page: - -```tsx - - } // 16px glyph, faint ink - title="mywork" // mono lowercase — console chrome - description="what this page is" // truncates first - actions={ ``` -Rename tab labels if "terminal" is wrong for your UX (`custom` / `preview` / keep generic **preview** + **raw json**). +The console reuses an already-open page or places it beside chat without +replacing an existing pane. The target page receives `panelContext` in its +`PageRenderProps`; react to `panelContext.id`, not only object identity, so a +second click on the same item still opens it. Keep the payload JSON-sized and +send opaque ids for content that the page can fetch lazily. The shell +file-change renderer is the reference: filename → exact snapshot diff, and +“View file” → Monaco editor. ### 5. Storybook stories (required) @@ -401,37 +423,9 @@ Approve/deny handlers are props on `FunctionCallMessage`; custom modules do not ## Scale beyond one family -Duplicating `SandboxToolView` imports in FCM does not scale. Suggested refactor (not implemented yet): - -``` -src/components/chat/function-plugins/ - types.ts # FunctionCallRenderer interface - registry.ts # ordered list of plugins - index.ts # resolvePreview(message), resolveTerminal(message), resolveLabel(functionId) -``` - -```typescript -export interface FunctionCallRenderer { - id: string - isMatch: (functionId: string) => boolean - tryRender: (message: FunctionCallMessage) => React.ReactNode | null - tryRenderPreview?: (message: FunctionCallMessage) => React.ReactNode | null - FunctionIdLabel?: (props: { functionId: string }) => React.ReactNode - /** Tab label when this renderer wins; default "preview" */ - primaryTabLabel?: string -} -``` - -FCM becomes: - -```typescript -const terminal = !pending ? resolveTerminal(message) : null -const preview = resolvePreview(message) -``` - -Register `sandboxPlugin` and `myFeaturePlugin` in `registry.ts`. First non-null win, or explicit priority field. +The ordered registry already supports both runtime-injected and first-party families. Runtime registrations are fenced and prepended in registration order; first-party renderers follow; the raw JSON card is the final fallback. `firstRendered()` returns both the node and its owner so `metadata.display` cannot accidentally promote content produced by a later renderer. -Until that exists, follow the **minimal wiring** in step 4 above. +Keep worker logic in its worker UI whenever it can ship with the function. Add a first-party renderer only for console-owned functions or when the worker cannot ship assets. A focused renderer may claim one result shape (for example an image), return `null` for every other shape, and sit before its general family renderer. --- diff --git a/console/web/src/App.tsx b/console/web/src/App.tsx index 72c49a8e4..6e55ba574 100644 --- a/console/web/src/App.tsx +++ b/console/web/src/App.tsx @@ -28,6 +28,7 @@ import { ConversationsProvider, useConversationsCtx, } from '@/lib/conversations-context' +import { subscribePanelOpen } from '@/lib/panel-context' import { loadEdgeAddDiscovered, saveEdgeAddDiscovered } from '@/lib/storage' import { cn } from '@/lib/utils' import { @@ -84,6 +85,13 @@ export function App() { ) const workspaceRef = useRef(workspace) workspaceRef.current = workspace + useEffect( + () => + subscribePanelOpen((event) => { + workspaceRef.current.openScreen(`ext:${event.pageId}`) + }), + [], + ) // Closing settings routes back to the ACTIVE tab's own screen (never to // whichever tab happens to own the previous view — that would switch // tabs under the user). Pre-marking keeps the hash-inbound effect quiet. diff --git a/console/web/src/components/chat/FunctionTriggerMessage.stories.tsx b/console/web/src/components/chat/FunctionTriggerMessage.stories.tsx index 3b4a76904..d2a1a5f2e 100644 --- a/console/web/src/components/chat/FunctionTriggerMessage.stories.tsx +++ b/console/web/src/components/chat/FunctionTriggerMessage.stories.tsx @@ -1,5 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' +import { useEffect } from 'react' import { FunctionTriggerCard } from '@/components/function-trigger/FunctionTriggerCard' +import { registerExtRenderer } from '@/lib/ui-slots' import { coderFixtures } from '@/stories/fixtures/coder-fixtures' import { directoryFixtures } from '@/stories/fixtures/directory-fixtures' import { engineFixtures } from '@/stories/fixtures/engine-fixtures' @@ -15,6 +17,9 @@ import { workerFixtures } from '@/stories/fixtures/worker-fixtures' import { workflowFixtures } from '@/stories/fixtures/workflow-fixtures' import { worktreeFixtures } from '@/stories/fixtures/worktree-fixtures' import type { FunctionTriggerMessage as FTriggerType } from '@/types/chat' +import { FileChangesCard } from '../../../../../shell/ui/src/function-trigger/FileChangesCard' +import { summarizeFileChanges } from '../../../../../shell/ui/src/function-trigger/file-changes' +import '../../../../../shell/ui/styles.css' const ftriggerPendingSingle: FTriggerType = { id: 'f0a', @@ -42,6 +47,7 @@ const ftriggerRunning: FTriggerType = { id: 'f1', role: 'function-trigger', functionId: 'engine::echo', + description: 'Checking the service response', input: { text: 'hello, world.' }, running: true, createdAt: Date.now(), @@ -57,6 +63,92 @@ const ftriggerDone: FTriggerType = { createdAt: Date.now(), } +const ftriggerActivity: FTriggerType = { + ...ftriggerDone, + id: 'f2-activity', + description: 'Checking the service response', +} + +const fileChangesActivity: FTriggerType = { + id: 'f-files-activity', + role: 'function-trigger', + functionId: 'coder::update-file', + description: 'Updating project files', + input: { + files: [ + 'pnpm-workspace.yaml', + 'storage/Cargo.toml', + 'storage/README.md', + 'src/config.ts', + 'src/index.ts', + 'tests/config.test.ts', + 'docs/setup.md', + 'package.json', + ].map((path, index) => ({ + path, + ops: [ + { + op: 'update_lines', + from_line: index + 1, + to_line: index + 1, + content: `updated line ${index + 1}\nadded line\n`, + }, + ], + })), + }, + output: { + results: [ + 'pnpm-workspace.yaml', + 'storage/Cargo.toml', + 'storage/README.md', + 'src/config.ts', + 'src/index.ts', + 'tests/config.test.ts', + 'docs/setup.md', + 'package.json', + ].map((path, index) => ({ + path: `/repo/${path}`, + success: true, + applied: 1, + change_id: `story-change-${index}`, + })), + }, + durationMs: 824, + createdAt: Date.now(), +} + +const fileChangesSummary = summarizeFileChanges( + fileChangesActivity.functionId, + fileChangesActivity.input, + fileChangesActivity.output, +) + +function FileChangesActivityStory() { + useEffect( + () => + registerExtRenderer({ + renderer: { + id: 'shell/story#file-changes', + isMatch: (functionId) => functionId === 'coder::update-file', + tryRender: () => + fileChangesSummary ? ( + {}} + onOpenFile={() => {}} + /> + ) : null, + metadata: { display: true }, + }, + scope: 'shell', + path: 'shell/page.js', + }), + [], + ) + return +} + const ftriggerDoneMulti: FTriggerType = { id: 'f3', role: 'function-trigger', @@ -170,6 +262,16 @@ export const DoneCollapsed: Story = { args: { message: ftriggerDone }, } +export const DescribedActivity: Story = { + name: 'agent activity (collapsed)', + args: { message: ftriggerActivity }, +} + +export const FileChangesArtifact: Story = { + name: 'agent activity · file changes artifact', + render: () => , +} + export const DoneExpanded: Story = { name: 'triggered · single-field (expanded)', args: { message: ftriggerDone, defaultOpen: true }, diff --git a/console/web/src/components/function-trigger/FunctionTriggerCard.tsx b/console/web/src/components/function-trigger/FunctionTriggerCard.tsx index d02bb12fd..2dfe09102 100644 --- a/console/web/src/components/function-trigger/FunctionTriggerCard.tsx +++ b/console/web/src/components/function-trigger/FunctionTriggerCard.tsx @@ -1,8 +1,9 @@ -import { Check, Copy, X } from 'lucide-react' +import { Check, Copy, Loader2, X } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import { CopyMessageButton } from '@/components/chat/CopyMessageButton' import { firstNonNull, + firstRendered, rawRedactor, useFunctionTriggerRenderers, } from '@/components/function-trigger/renderer-registry' @@ -205,6 +206,25 @@ function FunctionIdLabel({ functionId }: { functionId: string }) { return {functionId} } +function FunctionIdentityRow({ functionId }: { functionId: string }) { + return ( +
+ + function + + + ƒ{' '} + + + +
+ ) +} + /** * One-line `key: value` digest of the request args for the collapsed header — * what separates three settled calls to the same function without expanding @@ -269,6 +289,11 @@ export function FunctionTriggerCard({ : undefined const filesystemAccess = pending ? message.filesystemAccess : undefined const [open, setOpen] = useState(!!defaultOpen || pending) + // Closed calls read as a lightweight activity list. Opening one restores + // the full raised function-call surface with the existing panes and + // controls. Pending approvals remain surfaces because they require action. + const expandedSurface = open || pending + const [showRawDetails, setShowRawDetails] = useState(false) const [tab, setTab] = useState<'terminal' | 'json'>('terminal') const [submitting, setSubmitting] = useState< 'approve' | 'deny' | 'always_allow' | null @@ -279,14 +304,20 @@ export function FunctionTriggerCard({ renderers, (r) => r.tryRenderPreview?.(message) ?? null, ) - const customTerminal = !pending - ? firstNonNull(renderers, (r) => + const terminalRender = !pending + ? firstRendered(renderers, (r) => running ? (r.tryRenderRunning ?? r.tryRender)(message) : r.tryRender(message), ) : null + const customTerminal = terminalRender?.node ?? null const hasCustomTerminal = customTerminal != null + const displayCustomTerminal = + !pending && + !running && + hasCustomTerminal && + terminalRender?.renderer.metadata?.display === true // The top request pane renders only while the call is in flight and no // richer view covers it; the settled (done) branch below renders its own // request/response panes, so showing it there would duplicate the pane. @@ -334,13 +365,20 @@ export function FunctionTriggerCard({ // a claimed card's `redactRaw` has to cover it or a secret shows up in the // one line that renders without anyone expanding the card. const preview = argsPreview(rawInput) + const description = message.description?.trim() || undefined return (
+
- {!(running && message.unresolvedTarget) ? ( + {!description && !(running && message.unresolvedTarget) ? (