feat: let plugin tools show native views in chat (#173135)

* feat: let plugin tools show native views in chat

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>

* fix: complete native inline view integration

Repair wrapper inventory, plugin test ownership, SDK surface and migrated UI mocks. Preserve canvas behavior in a cohesive helper and real exports in the census fixture.

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>

* test: prove restored inline view authority through real gateway

Exercise allowed and read-only incident mutations and retired mount requests during authentic hydration without changing production behavior.

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>

* test: complete inline proof inventory and control batch clock

Register the private Gateway fixture in the ownership expectation. Keep the Levanto batch-conversion test independent of wall-clock scheduling while preserving separate deadline and abort coverage.

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>

* test: adapt inline view proof to current runtime owners

Narrow the consolidated tab fixture and compile the isolated incident fixture beside its declared development dependencies. Remove the retired heartbeat fixture key without changing authority assertions.

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>

* chore: accept the native inline view startup allowance

Raise the startup baseline and committed ceiling by the owner-approved 50 bytes, preserving the separate growth and build-variance allowances. Verify acceptance at the exact effective limit and rejection one byte above it.

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>

* test: complete the ClawRouter native UI host fixture

Keep the explicit pool fixture aligned with the new mandatory inline-view registration surface. Unexpected use still fails through the existing assertion helper.

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>

* fix: include inline view runtime in PR wrapper contexts

Add the inline-view emitter to the wrapper component inventory so extracted wrappers and cold worktree provisioning retain the runtime import required by the plugin API builder.

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>

* test: wait for intended confirmation and panel controls

Bind confirmation readiness to its actual content and require the requested mobile or desktop Panels presentation. Preserve interaction assertions while covering delayed rendering and stale opposite controls.

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>

* test: retain desktop header geometry in readiness fixtures

Match the production sibling header structure required by the upstream layout-settlement guard while preserving delayed and stale-control assertions.

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>

* test: keep confirmation readiness generic and caller-scoped

Restore custom dialog readiness without a production-class whitelist. Scope stacked WhatsApp confirmations before waiting and exercise the explicit target contract through Gateway switching and worker-stop coverage.

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>

* test: preserve caller-owned confirmation targets

Translate the three upstream selector arguments to the reviewed generic modal-locator contract without changing assertions or production behavior.

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>

* fix: retire inline plugin views when retained chats hide

Consume the canonical transcript presentation context alongside viewport proximity so hidden retained rows dispose their native plugin mounts. Verify retirement and equal-prop remount through the actual transcript boundary.

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>

* test: cover registered inline tool invocation binding

Exercise admitted factory results, frozen Proxy tools, nested invocation provenance, and lifecycle retirement.

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>

* test: align prebuilt Gateway inventory expectations

Keep all fifty real-Gateway cases while reflecting the seven fixtures moved to prebuilt parallel execution by #181407. Preserve the independent serial inventory and scheduling/resource assertions.

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>

* test: isolate startup budget fixtures from baseline ratchets

Exercise explicit original and lowered synthetic baselines while preserving the approved cap, growth and variance allowances, and exact one-byte-over rejection.

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>

* test: await workspace preview completion before focus checks

Keep the controlled file read, preview identity, publication and original focus/selection assertions while awaiting the existing owner completion promise instead of an incidental polling deadline.

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>

* test: record rail menu Escape state on failure

Capture bounded passive input and accepted overlay state only when the existing browser assertion fails. Preserve all interactions, assertions and timeouts; this adds diagnostics without claiming a behavioral fix.

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>

* fix: show singleton dashboard controls after cold loading

Derive page-widget eligibility from the loaded snapshot rather than an unobserved lazy renderer registration. Preserve mounted-view action admission and add cold-load coverage. Retain failure-only rubric diagnostics without changing its deadline or claiming the historical CI cause.

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>

* test: isolate decision conversion fixtures from wall-clock latency

Keep controlled time in the five conversion cases while preserving the finite 500ms budget and separate deadline/abort coverage. Historical CI failure causality remains unqualified.

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>

* test: give decision registration fixtures one clock owner

Use controlled time for behavioral fixtures while retaining finite caller deadlines and explicit expiry/abort coverage. Remove duplicate conversion/deadline clock setup; historical CI timing remains unqualified.

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>

---------

Co-authored-by: jacobtomlinson <1610850+jacobtomlinson@users.noreply.github.com>
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-23 16:26:28 +00:00
1 parent e84dae9fae
commit 1c2c8f033c
88 files changed
+3449 -208

No files matched your search

+159
View File
@@ -143,6 +143,7 @@ Its id must match the plugin manifest. Register contributions through
| `registerAction` | Composer, header, or session menu actions. An optional `resolve` function supplies the current label, hidden state, and disabled state. |
| `registerPanel` | Session panels. |
| `registerAccessory` | Session header content. |
| `registerInlineView` | Data-populated native views in the chat transcript. |
| `registerWidget` | Native dashboard widget views. |
| `registerReplacement` | `workspace`, `session-list`, `composer`, `transcript`, or `tool-result`. |
@@ -304,6 +305,164 @@ plugin activation closes a dock that activation still owns. Use
changes. See the [dock contract](/plugins/sdk-subpaths#control-ui-conversation-dock)
for replacement, visibility, access, and context limits.
## Inline views from tools
A native plugin can register a predefined inline component and return **data** for
it from a tool. The agent selects the tool and its arguments; it does not author
HTML, JavaScript, templates, or a component. This is native plugin UI, not a
sandboxed widget or MCP App.
Register the backend display contract and return its descriptor:
```typescript
api.session.controls.registerControlUiDescriptor({
id: "incident-dashboard",
surface: "inline",
label: "Incident dashboard",
requiredScopes: ["operator.read"],
version: 1,
});
api.registerTool({
name: "show_incident",
label: "Show incident",
description: "Show an incident dashboard.",
parameters: {
type: "object",
properties: { incidentId: { type: "string" } },
required: ["incidentId"],
},
async execute(_toolCallId, args: { incidentId: string }) {
return api.inlineView(
"incident-dashboard",
{ incidentId: args.incidentId },
{
version: 1,
summary: `Showing incident ${args.incidentId}.`,
},
);
},
});
```
The helper binds the canonical manifest plugin id (not the display name). It
returns ordinary text content containing the required nonblank summary, plus
`details: { pluginInlineView: { pluginId, viewId, version, payload, summary } }`.
Payloads must be plain JSON, at most **16 KiB of serialized UTF-8**. Summaries
are at most **4 KiB of serialized UTF-8 JSON**, keeping the complete result within nested tool
history limits. Store larger data in the plugin and pass an ordinary resource
id. The helper requires an explicit integer interface version from **1 to 65535**.
The native tool host binds the record to its originating invocation; model text,
arbitrary MCP results, and imported external JSON cannot claim that association.
Core checks bounded JSON, not plugin data semantics. Plugin code owns semantic
validation, and HTML-looking strings are not inspected or blocked by core.
In the browser entry, register the matching installed component:
```typescript
export default defineControlUiPlugin({
id: "incident-tools",
activate(host) {
host.ui.registerInlineView({
id: "incident-dashboard",
version: 1,
mount(element, context) {
const heading = document.createElement("h3");
heading.textContent = "Incident dashboard";
element.append(heading);
// Read context.props.payload and fetch the resource through context.host.
return { dispose: () => heading.remove() };
},
});
},
});
```
The host supplies `payload`, `toolCallId`, `toolName`, `sessionKey`, and
`agentId`. Payload fields cannot override that context. Each tool call owns
an independent view, keyed by its owning run and tool-call id; repeated receipt of the same
result does not add another instance. There is no in-place tool-driven update
protocol in this version. Use plugin-owned state and `host.ui.invalidate()`
for interactive state shared with another view.
- **Live:** the view appears as soon as its successful tool result arrives,
after its summary, without waiting for the agent final reply or expanding
tool details. Direct and nested Tool Search calls use their actual call identity.
- **History:** reload reconstructs the display from the stored descriptor,
never by rerunning the agent or tool. Only rows near the scroll viewport mount
components. Offscreen instances are disposed/aborted; a measured-height
placeholder preserves scrolling while the row remains present. Virtualization
can remove the entire row, and scrolling back may mount a new instance.
- **Availability:** the backend descriptor, browser registration, current viewer
scopes, plugin enablement, and Custom plugin UI lab must still permit the view.
Missing plugins/views, unsupported interface versions, loading, and activation
failures preserve the saved summary. Availability is derived at presentation
time; it is not stored or transferred in the record and never discards payload.
Reinstalling a compatible renderer can display the same saved data without
rerunning tools. No original caller privilege is inherited by a later viewer.
- **Errors:** a thrown mount/update error or `context.reportError?.(error)`
retires that instance and offers **Retry plugin view**. A stale instance cannot
report an error against its replacement. Handle ordinary action errors inside
your component when a full remount is not appropriate.
- **Versioning:** the backend declaration, browser registration, and saved record
must match exactly on plugin id, view id, and interface version. This version is
independent of the plugin package version. Register versions 1 and 2 alongside
each other to keep both histories displayable; each can reuse or adapt plugin
code. Core never guesses the newest renderer from a schema, archives renderer
bundles, or migrates payloads. Unversioned records do not mount native views.
- **Other surfaces:** text-only clients/model context receive the tool normal
summary content; this feature does not force channel tool-progress delivery.
Inline views are not dashboard pins or hosted/embed dispatch targets.
### Persistence and transfer
The canonical durable record is { pluginId, viewId, version, payload, summary }.
Native transcript storage and the native HTML session export retain it and its
host-bound invocation metadata, including when the plugin is absent. The HTML
export embeds the full session data, not executable plugin renderer bundles; it
does not mount native plugins itself or provide a general rich-session importer.
Control UI Markdown export and the native session-catalog/Beam mirror are
**minimal text exports**: they deliberately emit the saved summary, without the
component payload or mounting authority. Beam's current closed text protocol
and catalog text imports do **not** implement full-fidelity inline-view transfer.
External imports remain untrusted text and do not become authorized native views.
Both historical data policies are valid and plugin-owned:
- **Dynamic reference:** save an ordinary resource id and read fresh data when
mounted. The incident example follows this policy. Historical loss, deleted
resources, and changed server state are the plugin's responsibility.
- **Snapshot:** save self-contained bounded data for a historical display.
Core restores the saved reference/data with an explicitly compatible installed
renderer; it does not promise identical historical pixels or prohibit network
reads. Mounting may hydrate through plugin Gateway methods, but never reruns the
original tool or model.
### Inline plugin author responsibilities
Mounting must be repeatable display/read work. Do not acknowledge an incident,
submit a payment, or mutate a resource just because history was scrolled into
view. Mutations belong to an explicit click or the original authorized tool.
Release subscriptions, observers, and framework roots in `dispose`, and honor
`context.signal` after asynchronous work.
Native plugins run with the existing installation trust boundary and current
connection authority. The host does not add a sandbox, per-plugin RPC allowlist,
or generic HTML policing. Plugin authors own safe rendering: prefer
`textContent`/framework text interpolation for data. If your component uses
HTML, URL, script, or other executable sinks, validate/sanitize for that sink.
Never persist credentials, secrets, or bearer-capability references in a payload
or summary. On every backend request, authorize the **current viewer** against
resource/session ACLs; a saved resource id is not authorization.
See the runnable [incident example](https://github.com/openclaw/openclaw/tree/main/examples/inline-incident):
it reads current status, acknowledges through a scope-checked plugin Gateway
method on an explicit button click, and shares plugin state with a session panel
opened using both `sessionKey` and `agentId`.
## Build and reload
`package.json` names the browser **source**:
+11 -10
View File
@@ -438,16 +438,17 @@ plugin code.
}
```
| Value | Contribution |
| ------------- | ---------------------------------------------------- |
| `page` | A dedicated plugin page. |
| `navigation` | A navigation entry that opens a plugin page. |
| `panel` | A panel within an existing view. |
| `action` | A user-facing action in an existing view. |
| `accessory` | A small addition such as a session-header accessory. |
| `widget` | A dashboard widget. |
| `replacement` | A replacement for a supported host UI surface. |
| `link-reader` | A reader or preview for supported links. |
| Value | Contribution |
| ------------- | -------------------------------------------------------------- |
| `page` | A dedicated plugin page. |
| `navigation` | A navigation entry that opens a plugin page. |
| `panel` | A panel within an existing view. |
| `action` | A user-facing action in an existing view. |
| `accessory` | A small addition such as a session-header accessory. |
| `widget` | A dashboard widget. |
| `inline` | A native view populated by tool-result data in a conversation. |
| `replacement` | A replacement for a supported host UI surface. |
| `link-reader` | A reader or preview for supported links. |
The field is optional. Omission means unspecified; `[]` explicitly declares no
UI contributions. OpenClaw ignores an invalid declaration (unknown values or a
+39
View File
@@ -0,0 +1,39 @@
# Native inline incident views
A complete, synthetic example of a plugin tool returning a data descriptor for
an installed browser component. No tool or model is rerun when history mounts.
From the repository root of an OpenClaw source checkout containing native inline view support:
```sh
pnpm install --frozen-lockfile
pnpm --dir examples/inline-incident build
pnpm --dir examples/inline-incident validate
pnpm openclaw plugins install ./examples/inline-incident
```
Enable **Settings → Labs → Custom plugin UI**, then ask an agent to show incident
INC-42. The native card appears immediately. Acknowledge changes the synthetic
record through an `operator.write` Gateway method, not a model turn. Open incident
panel uses the card session and agent and the shared plugin module state.
Reload or scroll away/back to exercise repeatable read-only mounting. The saved
version-1 payload contains only an incident id: each mount may fetch fresh state.
It is deliberately not a snapshot. Deleted or changed resources are the plugin's
responsibility; a self-contained historical snapshot would also be valid.
Backend declaration, tool emission, and renderer all use interface version 1
(independent of package version). To change that payload contract, register an
explicit new version and retain the old registration if old records should render.
Missing or unsupported renderers leave the saved summary and payload intact.
The example's development dependency links to this workspace's OpenClaw; when
copying it into a standalone project, replace that workspace link with your
installed OpenClaw version.
The two demo records are shared by all authorized operators; state intentionally
resets when the backend plugin reloads. Production backends must check resource
ACLs for the current viewer and use plugin-owned durable storage. Never use a
persisted id or the original tool caller privileges as authority.
Rendering uses `textContent`, not HTML interpolation. See the
[feature plugin guide](https://docs.openclaw.ai/plugins/feature-plugins#inline-views-from-tools)
for payload bounds, historical compatibility, lifecycle, and trust responsibilities.
+25
View File
@@ -0,0 +1,25 @@
.incident-tools-card {
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
background: var(--bg-elevated);
}
.incident-tools-eyebrow {
font-size: 12px;
font-weight: 600;
color: var(--accent);
letter-spacing: 0.04em;
}
.incident-tools-card h3 {
margin: 10px 0;
font-size: 20px;
}
.incident-tools-status {
color: var(--muted);
}
.incident-tools-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
+182
View File
@@ -0,0 +1,182 @@
import {
defineControlUiPlugin,
type ControlUiHost,
type ControlUiViewContext,
} from "openclaw/plugin-sdk/control-ui";
import "./control-ui.css";
type Incident = {
incidentId: string;
title: string;
service: string;
severity: string;
status: string;
acknowledged: boolean;
};
type Session = { sessionKey: string; agentId?: string };
// Shared module state connects the inline view and panel. Only the two demo IDs are retained.
const snapshots = new Map<string, Incident>();
let selection: (Session & { incidentId: string }) | undefined;
function mountIncident(
container: HTMLElement,
initial: ControlUiViewContext<Session>,
incidentId: string,
panel: boolean,
) {
let context = initial;
let disposed = false;
const card = document.createElement("section");
card.className = "incident-tools-card";
const eyebrow = document.createElement("div");
eyebrow.className = "incident-tools-eyebrow";
const title = document.createElement("h3");
const status = document.createElement("p");
status.className = "incident-tools-status";
const output = document.createElement("p");
output.setAttribute("role", "status");
const controls = document.createElement("div");
controls.className = "incident-tools-controls";
const acknowledge = document.createElement("button");
acknowledge.type = "button";
acknowledge.className = "btn btn--sm";
const details = document.createElement("button");
details.type = "button";
details.className = "btn btn--sm";
details.textContent = "Open incident panel";
const render = () => {
const incident = snapshots.get(incidentId);
eyebrow.textContent = incident ? incident.severity + " · " + incident.incidentId : incidentId;
title.textContent = incident?.title ?? "Loading incident…";
status.textContent = incident
? incident.service + " · " + incident.status
: "Fetching current status";
acknowledge.textContent = incident?.acknowledged ? "Acknowledged" : "Acknowledge";
acknowledge.disabled = !incident || incident.acknowledged || !context.host.connection.canWrite;
};
acknowledge.addEventListener("click", () => {
acknowledge.disabled = true;
void context.host
.request<Incident>("incident-tools.acknowledge", { incidentId })
.then((incident) => {
if (disposed) {
return;
}
context.signal.throwIfAborted();
snapshots.set(incidentId, incident);
context.host.ui.invalidate();
render();
})
.catch((error: unknown) => {
if (!disposed && !context.signal.aborted) {
output.textContent = String(error);
render();
}
});
});
details.addEventListener("click", () => {
selection = {
sessionKey: context.props.sessionKey,
agentId: context.props.agentId,
incidentId,
};
context.host.ui.invalidate();
context.host.ui.openPanel("incident-details", {
sessionKey: context.props.sessionKey,
agentId: context.props.agentId,
});
});
controls.append(acknowledge);
if (!panel) {
controls.append(details);
}
card.append(eyebrow, title, status, controls, output);
container.append(card);
render();
// Mount is repeatable and read-only; acknowledgments happen only on explicit clicks.
void context.host
.request<Incident>("incident-tools.get", { incidentId })
.then((incident) => {
if (disposed) {
return;
}
context.signal.throwIfAborted();
snapshots.set(incidentId, incident);
render();
})
.catch((error: unknown) => {
if (!disposed && !context.signal.aborted) {
context.reportError?.(error);
}
});
return {
update(next: ControlUiViewContext<Session>) {
context = next;
render();
},
dispose() {
disposed = true;
card.remove();
},
};
}
export default defineControlUiPlugin({
id: "incident-tools",
activate(host: ControlUiHost) {
host.ui.registerInlineView({
id: "incident-dashboard",
version: 1,
mount(container, context) {
const payload = context.props.payload;
if (
!payload ||
typeof payload !== "object" ||
Array.isArray(payload) ||
typeof payload.incidentId !== "string"
) {
throw new Error("This incident payload is unsupported.");
}
return mountIncident(container, context, payload.incidentId, false);
},
});
host.ui.registerPanel({
id: "incident-details",
label: "Incident details",
mount(container, context) {
let currentId: string | undefined;
let mounted: ReturnType<typeof mountIncident> | undefined;
const update = (next: typeof context) => {
const selected = selection;
const id =
selected?.sessionKey === next.props.sessionKey &&
selected.agentId === next.props.agentId
? selected.incidentId
: undefined;
if (id !== currentId) {
mounted?.dispose();
container.replaceChildren();
currentId = id;
mounted = id ? mountIncident(container, next, id, true) : undefined;
} else {
mounted?.update(next);
}
if (!id) {
container.textContent = "Open an incident from its inline card.";
}
};
update(context);
return {
update,
dispose() {
mounted?.dispose();
},
};
},
});
return () => {
snapshots.clear();
selection = undefined;
};
},
});
+96
View File
@@ -0,0 +1,96 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
// Synthetic, shared demo records; intentionally reset on plugin reload.
const incidents = new Map([
[
"INC-42",
{
incidentId: "INC-42",
title: "Elevated checkout latency",
service: "Checkout API",
severity: "SEV 2",
status: "Investigating",
acknowledged: false,
},
],
[
"INC-43",
{
incidentId: "INC-43",
title: "Delayed search indexing",
service: "Search",
severity: "SEV 3",
status: "Monitoring",
acknowledged: false,
},
],
]);
export default definePluginEntry({
id: "incident-tools",
name: "Incident tools",
description: "Show a predefined native incident dashboard.",
register(api) {
api.session.controls.registerControlUiDescriptor({
id: "incident-dashboard",
surface: "inline",
label: "Incident dashboard",
requiredScopes: ["operator.read"],
version: 1,
});
api.registerTool({
name: "show_incident",
label: "Show incident",
description: "Show incident INC-42 or INC-43 in the conversation.",
parameters: {
type: "object",
properties: { incidentId: { type: "string", enum: ["INC-42", "INC-43"] } },
required: ["incidentId"],
},
async execute(_toolCallId, args: { incidentId: string }) {
const incident = incidents.get(args.incidentId);
if (!incident) {
throw new Error("Unknown demo incident. Choose INC-42 or INC-43.");
}
return api.inlineView(
"incident-dashboard",
{ incidentId: incident.incidentId },
{
version: 1,
summary: incident.incidentId + ": " + incident.title + " — " + incident.status + ".",
},
);
},
});
// Gateway dispatch enforces the current viewer's scope, not the tool caller's.
// All demo records are shared. Real backends must also check that viewer's record ACL.
api.registerGatewayMethod(
"incident-tools.get",
({ params, respond }) => {
const incident =
typeof params.incidentId === "string" ? incidents.get(params.incidentId) : undefined;
if (!incident) {
respond(false, undefined, { code: "INVALID_REQUEST", message: "Unknown demo incident." });
return;
}
respond(true, incident);
},
{ scope: "operator.read" },
);
api.registerGatewayMethod(
"incident-tools.acknowledge",
({ params, respond }) => {
const incident =
typeof params.incidentId === "string" ? incidents.get(params.incidentId) : undefined;
if (!incident) {
respond(false, undefined, { code: "INVALID_REQUEST", message: "Unknown demo incident." });
return;
}
const acknowledged = { ...incident, acknowledged: true };
incidents.set(incident.incidentId, acknowledged);
respond(true, acknowledged);
},
{ scope: "operator.write" },
);
},
});
@@ -0,0 +1,22 @@
{
"id": "incident-tools",
"name": "Incident tools",
"description": "Native inline incident views with a shared session panel.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
},
"contracts": {
"tools": [
"show_incident"
]
},
"activation": {
"onStartup": true
},
"uiCapabilities": [
"inline",
"panel"
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "openclaw-inline-incident-example",
"version": "1.0.0",
"private": true,
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
],
"controlUi": "./control-ui.ts"
},
"scripts": {
"build": "openclaw plugins build --root .",
"validate": "openclaw plugins validate --root ."
},
"peerDependencies": {
"openclaw": "*"
},
"devDependencies": {
"openclaw": "workspace:*",
"esbuild": "0.28.2"
}
}
+27 -4
View File
@@ -1,5 +1,7 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import type { SessionCatalogTranscriptItem } from "openclaw/plugin-sdk/session-catalog";
import * as sessionCatalogRuntime from "openclaw/plugin-sdk/session-catalog-runtime";
import * as ssrfRuntime from "openclaw/plugin-sdk/ssrf-runtime";
import { withServer } from "openclaw/plugin-sdk/test-env";
@@ -79,21 +81,42 @@ describe("parseBeamMirrorConfig", () => {
describe("buildBeamMirrorItems", () => {
it("restores chronological text from newest-first catalog items and summarizes raw content", () => {
const reduced = buildBeamMirrorItems([
const display = createTestPluginApi({ id: "incidents" }).inlineView(
"incident",
{ incidentId: "INC-42" },
{ version: 3, summary: "INC-42 is investigating." },
);
const items: SessionCatalogTranscriptItem[] = [
{ type: "agentMessage", text: "Done." },
{ type: "agentMessage", text: display.content[0].text, raw: display.details },
{ type: "reasoning", text: "private thoughts" },
{ type: "toolResult", raw: { output: "secret output" } },
{ type: "toolCall", text: "rm -rf /tmp/x", raw: { command: "secret" } },
{ type: "userMessage", text: "Fix it." },
]);
expect(reduced.items).toEqual([
];
const reduced = buildBeamMirrorItems(items);
const expected = [
{ type: "userMessage", text: "Fix it." },
{
type: "other",
text: "1 tool calls, 1 tool results, 1 reasoning items; raw content dropped",
},
{ type: "agentMessage", text: "INC-42 is investigating." },
{ type: "agentMessage", text: "Done." },
]);
];
expect(reduced.items).toEqual(expected);
expect(
parseBeamUpload({
version: 1,
beamId: "a".repeat(32),
source: "openclaw",
title: "Incident",
updatedAt: "2026-10-19T12:00:00.000Z",
completed: true,
items: reduced.items,
}),
).toMatchObject({ ok: true, value: { items: expected } });
expect(JSON.stringify(reduced.items)).not.toContain("pluginInlineView");
expect(JSON.stringify(reduced.items)).not.toContain("secret");
expect(JSON.stringify(reduced.items)).not.toContain("private thoughts");
});
@@ -86,6 +86,7 @@ async function fixture() {
registerAction: unused,
registerAccessory: unused,
registerWidget: unused,
registerInlineView: unused,
registerReplacement: unused,
selectReplacement: unused,
},
+18 -4
View File
@@ -9,7 +9,7 @@ import {
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { afterEach, expect, it, vi } from "vitest";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import plugin from "./index.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
@@ -107,6 +107,11 @@ const questionBatch = (id: string, question: DecisionQuestionV2): DecisionBatchV
});
const unavailable = (reason: string) => ({ status: "unavailable", reason });
const unsupported = unavailable("unsupported-input");
beforeEach(() => {
// The fixture owns a finite caller budget, not a cold request-policy benchmark.
// Only deadline scenarios advance time; HTTP, credentials and parsing stay real.
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date", "performance"] });
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
@@ -238,7 +243,7 @@ it("uses one native batch for mixed scalar kinds and retains partial failures an
};
const { provider, context, fetch } = fixture(Response.json(native));
const outcome = await provider.evaluate(input, context);
assert(outcome.status === "ok");
assert(outcome.status === "ok", JSON.stringify(outcome));
expect(validateDecisionResultV2(input, outcome.result)).toBe(true);
expect(outcome.result.answers).toMatchObject({
c: { choice: "a", probability: 0.7, probabilities: { a: 0.7, b: 0.3 } },
@@ -278,7 +283,17 @@ it.each([2, 26])("preserves a %i-level rubric and distribution", async (count) =
),
);
const outcome = await provider.evaluate(input, context);
assert(outcome.status === "ok");
assert(
outcome.status === "ok",
outcome.status === "ok"
? ""
: JSON.stringify({
status: outcome.status,
reason: outcome.reason,
fetchCount: fetch.mock.calls.length,
deadlineRemainingMs: context.deadlineMonotonicMs - performance.now(),
}),
);
expect(outcome.result.answers).toMatchObject({
score: {
type: "score",
@@ -392,7 +407,6 @@ it.each([
["abort", "image"],
["deadline", "image"],
])("settles %s during %s and prevents later work", async (kind, phase) => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date", "performance"] });
const started = createDeferred<void>();
const cancel = vi.fn();
const { provider, context, probe, fetch } = fixture(
@@ -124,6 +124,7 @@ export function createWorkboardTestHost() {
registerAction: register("action"),
registerAccessory: register("accessory"),
registerWidget: register("widget"),
registerInlineView: register("inline"),
registerReplacement: register("replacement"),
selectReplacement: vi.fn(),
},
@@ -6,6 +6,7 @@ export const PLUGIN_UI_CAPABILITIES = [
"action",
"accessory",
"widget",
"inline",
"replacement",
"link-reader",
] as const;
@@ -78,6 +78,7 @@ export const PluginControlUiDescriptorSchema = closedObject({
"tab",
"widget",
"link-reader",
"inline",
]),
linkReader: Type.Optional(ControlUiLinkReaderMetadataSchema),
label: NonEmptyString,
@@ -87,6 +88,7 @@ export const PluginControlUiDescriptorSchema = closedObject({
placement: Type.Optional(Type.String()),
group: Type.Optional(stringLiteralUnion(["control", "agent"])),
order: Type.Optional(Type.Number()),
version: Type.Optional(Type.Integer({ minimum: 1, maximum: 65535 })),
schema: Type.Optional(PluginJsonValueSchema),
requiredScopes: Type.Optional(Type.Array(NonEmptyString)),
});
@@ -159,6 +161,16 @@ export const PluginsControlUiReloadParamsSchema = closedObject({
export const PluginsControlUiCatalogSchema = closedObject({
revision: NonEmptyString,
plugins: Type.Array(PluginControlUiModuleSchema, { maxItems: 64 }),
inlineViews: Type.Optional(
Type.Array(
closedObject({
pluginId: NonEmptyString,
id: NonEmptyString,
label: NonEmptyString,
version: Type.Integer({ minimum: 1, maximum: 65535 }),
}),
),
),
diagnostics: Type.Array(PluginControlUiDiagnosticSchema, { maxItems: 64 }),
});
export const PluginsControlUiChangedEventSchema = closedObject({ revision: NonEmptyString });
+9
View File
@@ -633,6 +633,15 @@ importers:
specifier: workspace:*
version: link:../../packages/ai
examples/inline-incident:
devDependencies:
esbuild:
specifier: 0.28.2
version: 0.28.2
openclaw:
specifier: workspace:*
version: link:../..
extensions/a2a:
dependencies:
zod:
+3 -1
View File
@@ -65,7 +65,9 @@ const controlUiPerformanceBudgets = {
newBootJsGzipBytes: 807_612,
startupCssRequests: 1,
// Solid 2 action/optimistic runtime adoption; offset tracked by HF-STARTUP-BUDGET.
startupJsGzipBytes: 325_081,
// Jacob Tomlinson approved +50 B for native inline views (#173135, 2026-10-22).
// The separate 512 B growth and 64 B build-variance allowances are unchanged.
startupJsGzipBytes: 325_131,
// Keep 45 KiB advisory: tiny integrated changes must not exhaust the budget.
// The fixed 50 KiB ceiling bounds accumulation of small changes.
startupCssGzipBytes: 50 * KIB,
+3
View File
@@ -2599,6 +2599,7 @@ src/chat/canvas-render.ts
src/chat/tool-content.ts
src/gateway/chat-display-projection.canvas.ts
src/gateway/chat-display-projection.commentary.ts
src/gateway/chat-display-projection.inline-view.ts
src/gateway/chat-display-projection.sanitize.ts
src/gateway/worker-environments/workspace-conflicts.ts
src/infra/sibling-temp-file.ts
@@ -2609,6 +2610,7 @@ src/media/store.ts
src/sessions/session-run-error-presentation.ts
src/sessions/user-turn-media.ts
src/shared/channel-read-authority.ts
src/shared/plugin-inline-view.ts
src/shared/tool-approval-reviews.ts
src/agents/agent-lifecycle-parent-state.ts
src/agents/agent-run-terminal-error.ts
@@ -2658,6 +2660,7 @@ src/plugins/api-lifecycle.ts
src/plugins/cli-backend-registration.ts
src/plugins/cli-backends.runtime.ts
src/plugins/cli-callback-binding.ts
src/plugins/inline-view.ts
src/plugins/service-scheduler-binding.ts
src/plugins/setup-registry.ts
src/shared/map-nested-strings.ts
@@ -5,6 +5,11 @@ import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { generateExportHtmlVendorAssets } from "../../../scripts/runtime-postbuild.mts";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import { projectPluginInlineView } from "../../gateway/chat-display-projection.inline-view.js";
import {
createPluginInlineViewResult,
bindPluginInlineViewInvocation,
} from "../../plugins/inline-view.js";
import { buildCommandTextFromArgs, findCommandByNativeName } from "../commands-registry.js";
import { buildExportSessionReply } from "./commands-export-session.js";
import { commandFixture } from "./commands-handler-fixture.test-support.js";
@@ -152,6 +157,45 @@ describe("buildExportSessionReply", () => {
hoisted.sessionTranscriptEvents = [];
});
it("retains version, payload, fallback and invocation through the native HTML data export", async () => {
const result = bindPluginInlineViewInvocation(
createPluginInlineViewResult(
"incidents",
"incident",
{ incidentId: "INC-42" },
{ version: 3, summary: "INC-42 is investigating." },
),
"incidents",
"show_incident",
"call-1",
);
const message = {
role: "toolResult",
toolCallId: "call-1",
toolName: "show_incident",
...result,
__openclaw: { runId: "run-1" },
};
hoisted.sessionTranscriptEvents = [
{
type: "message",
id: "display-1",
parentId: null,
timestamp: "2026-10-19T12:00:00Z",
message,
},
];
const params = makeParams();
await buildExportSessionReply(params);
const restored = sessionDataFromHtml(await writtenHtml(params));
expect(restored.entries).toEqual(hoisted.sessionTranscriptEvents);
expect(projectPluginInlineView(result.details, message)).toMatchObject({
version: 3,
summary: "INC-42 is investigating.",
payload: { incidentId: "INC-42" },
});
});
it.each(["monthly report.html", "reports/monthly report.html"])(
"preserves the complete structured export path %j",
async (outputPath) => {
@@ -10,6 +10,7 @@ import {
normalizeToolApprovalReview,
} from "../shared/tool-approval-reviews.js";
import { truncateChatHistoryText } from "./chat-display-projection.helpers.js";
import { projectPluginInlineView } from "./chat-display-projection.inline-view.js";
function isDisplayRouteIdentifier(value: unknown, maxChars: number): value is string {
return (
@@ -36,12 +37,17 @@ export function isToolResultHistoryBlockType(type: unknown): boolean {
export function projectToolResultDetails(
details: unknown,
maxChars: number,
invocation: Record<string, unknown> = {},
): { details: Record<string, unknown> | undefined; truncated: boolean } {
const record = readRecord(details);
if (!record) {
return { details: undefined, truncated: false };
}
const projected: Record<string, unknown> = {};
const inline = projectPluginInlineView(record, invocation);
if (inline) {
projected.pluginView = inline;
}
const browserTab = readRecord(record.browserTab);
// A partial or shortened address can select a different browser. Only display
// text may be truncated; route identifiers must survive projection unchanged.
@@ -0,0 +1,33 @@
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import {
readPluginInlineViewBlock,
readPluginInlineViewDescriptor,
type PluginInlineViewBlock,
} from "../shared/plugin-inline-view.js";
type Invocation = Record<string, unknown>;
/** Preserve the admitted record regardless of installed renderers. The browser owns availability. */
export function projectPluginInlineView(
details: unknown,
invocation: Invocation,
): PluginInlineViewBlock | undefined {
const record = asOptionalRecord(details);
const descriptor = readPluginInlineViewDescriptor(record?.pluginInlineView);
const owner = asOptionalRecord(record?.pluginInlineViewInvocation);
const source =
descriptor &&
owner?.pluginId === descriptor.pluginId &&
typeof owner.toolCallId === "string" &&
typeof owner.toolName === "string"
? {
type: "plugin_view" as const,
...descriptor,
toolCallId: owner.toolCallId,
toolName: owner.toolName,
}
: readPluginInlineViewBlock(record?.pluginView);
const toolCallId = invocation.toolCallId ?? invocation.tool_call_id ?? invocation.id;
const toolName = invocation.toolName ?? invocation.tool_name ?? invocation.name;
return source?.toolCallId === toolCallId && source?.toolName === toolName ? source : undefined;
}
@@ -177,6 +177,10 @@ export function sanitizeChatHistoryContentBlock(
return { block, changed: false, truncated: false };
}
const entry = { ...(block as Record<string, unknown>) };
// Native inline blocks are host projections of admitted tool details, never transcript-authored UI.
if (entry.type === "plugin_view") {
return { block: { type: "text", text: "" }, changed: true, truncated: false };
}
let changed = stripPrivateToolCallContextForDisplay(entry);
// Display-cap truncation is a fact consumers need (to fetch the full row), so
// it is tracked apart from `changed`, which also covers metadata stripping.
@@ -189,7 +193,7 @@ export function sanitizeChatHistoryContentBlock(
? opts?.toolResultMaxChars
: undefined;
if (isToolResultHistoryBlockType(entry.type) && "details" in entry) {
const projectedDetails = projectToolResultDetails(entry.details, maxChars);
const projectedDetails = projectToolResultDetails(entry.details, maxChars, entry);
if (projectedDetails.details) {
entry.details = projectedDetails.details;
} else {
@@ -517,7 +521,7 @@ export function sanitizeChatHistoryMessage(
const conflictDetails = projectWorkspaceConflictDetails(entry);
const toolResultDetails =
!conflictDetails && messageHasToolResultShape(entry)
? projectToolResultDetails(entry.details, maxChars)
? projectToolResultDetails(entry.details, maxChars, entry)
: undefined;
// Only the terminal owner's presentation category crosses this report boundary.
// Correlation is projected separately; raw diagnostics and report fields stay private.
@@ -710,9 +714,6 @@ export function sanitizeChatHistoryMessages(
maxChars: number = DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS,
opts?: { includeCommentaryFallbacks?: boolean; toolResultMaxChars?: number },
): unknown[] {
if (messages.length === 0) {
return messages;
}
let changed = false;
const next: unknown[] = [];
for (const original of messages) {
+5 -2
View File
@@ -14,7 +14,10 @@ import {
} from "./control-ui-plugin-tabs.js";
import { createGatewayMethodRegistry } from "./methods/registry.js";
function descriptor(id: string, fields: Partial<PluginControlUiDescriptor>) {
function descriptor(
id: string,
fields: Partial<PluginControlUiDescriptor & { surface: "link-reader" | "tab" }>,
) {
return { id, label: id, surface: "link-reader" as const, ...fields };
}
@@ -130,7 +133,7 @@ describe("plugin link-reader discovery", () => {
pathPattern: "^/items/[0-9]+$",
detailMethod: "forge.read",
};
const invalid: Array<Partial<PluginControlUiDescriptor>> = [
const invalid: Parameters<typeof descriptor>[1][] = [
{ linkReader: { ...metadata, hosts: ["*.example"] } },
{ linkReader: { ...metadata, hosts: ["https://forge.example"] } },
{ linkReader: { ...metadata, pathPattern: "/items/" } },
+5 -2
View File
@@ -15,7 +15,10 @@ import {
listControlUiPluginWidgetKinds,
} from "./control-ui-plugin-tabs.js";
function tab(pluginId: string, overrides: Partial<PluginControlUiDescriptor> = {}) {
function tab(
pluginId: string,
overrides: Partial<PluginControlUiDescriptor & { surface: "tab" | "run" | "widget" }> = {},
) {
const descriptor: PluginControlUiDescriptor = {
id: "logbook",
surface: "tab",
@@ -55,7 +58,7 @@ function activateDescriptors(
}
function activateRoutes(
entries: Array<[string, string, Partial<PluginControlUiDescriptor>?]>,
entries: Array<[string, string, Parameters<typeof tab>[1]?]>,
routes: Parameters<typeof activateDescriptors>[1],
) {
return activateDescriptors(
+14 -1
View File
@@ -71,7 +71,19 @@ function visibleDescriptors(
export function listControlUiPluginDescriptors(
scopes: readonly string[],
): WireControlUiDescriptor[] {
return visibleDescriptors(getPluginRegistryForContext()?.controlUiDescriptors ?? [], scopes)
const registry = getPluginRegistryForContext();
return visibleDescriptors(registry?.controlUiDescriptors ?? [], scopes)
.filter(
(entry) =>
entry.descriptor.surface !== "inline" ||
registry?.plugins.some(
(plugin) =>
plugin.id === entry.pluginId &&
plugin.enabled &&
plugin.status === "loaded" &&
isControlUiPluginAllowed(plugin),
),
)
.map(({ pluginId, pluginName, descriptor }) => ({
pluginId,
pluginName,
@@ -82,6 +94,7 @@ export function listControlUiPluginDescriptors(
description: descriptor.description,
placement: descriptor.placement,
schema: descriptor.schema,
version: descriptor.version,
requiredScopes: descriptor.requiredScopes,
icon: descriptor.icon,
path: descriptor.path,
+17
View File
@@ -15,6 +15,7 @@ import {
appendChatCanvasBlocksToMessage,
type ChatCanvasBlock,
} from "./chat-display-projection.canvas.js";
import { projectPluginInlineView } from "./chat-display-projection.inline-view.js";
import type { GatewayBroadcastOpts } from "./server-broadcast-types.js";
import type { ChatRunState } from "./server-chat-state.js";
@@ -204,6 +205,22 @@ export function prepareAgentWirePayload(
};
}
}
if (event.stream === "tool" && event.data.phase === "result") {
const result = readRecord(payload.data.result);
const inline = !event.data.isError && projectPluginInlineView(result?.details, event.data);
if (inline && result) {
payload = {
...payload,
data: {
...payload.data,
result: {
...result,
details: { ...readRecord(result.details), pluginView: inline },
},
},
};
}
}
if (event.stream === "item" && event.data.kind === "preamble") {
payload = {
...payload,
@@ -13,6 +13,7 @@ import {
reportControlUiPluginActivation,
reloadControlUiPluginCatalog,
} from "../control-ui-plugin-assets.js";
import { listControlUiPluginDescriptors } from "../control-ui-plugin-tabs.js";
import type { GatewayRequestHandlers } from "./types.js";
import { defineValidatedGatewayHandler } from "./validation.js";
@@ -87,9 +88,24 @@ export const pluginsControlUiHandlers: GatewayRequestHandlers = {
"plugins.controlUi.list": defineValidatedGatewayHandler(
"plugins.controlUi.list",
validatePluginsControlUiListParams,
async ({ respond }) => {
async ({ respond, client }) => {
const catalog = await listControlUiPluginCatalog();
respond(true, catalog);
const inlineViews = listControlUiPluginDescriptors(client?.connect.scopes ?? []).flatMap(
(descriptor) => {
if (descriptor.surface !== "inline") {
return [];
}
return [
{
pluginId: descriptor.pluginId,
id: descriptor.id,
label: descriptor.label,
version: descriptor.version!,
},
];
},
);
respond(true, { ...catalog, ...(inlineViews.length ? { inlineViews } : {}) });
},
() => errorShape(ErrorCodes.UNAVAILABLE, "Control UI plugin catalog is unavailable"),
),
+14 -3
View File
@@ -15,6 +15,7 @@ import { withSessionEntryReadOnlyInWorker } from "../config/sessions/session-ent
import { readSessionHistoryPageInWorker } from "../config/sessions/session-history-worker-runtime.js";
import { resolveSessionStorePathForScope } from "../config/sessions/session-store-path.js";
import type { SessionEntry } from "../config/sessions/types.js";
import { readPluginInlineViewBlock } from "../shared/plugin-inline-view.js";
import { isAssistantTextContentType } from "./chat-display-projection.helpers.js";
import { projectChatDisplayMessages } from "./chat-display-projection.js";
import { prepareSessionCatalogSourceParticipantProjector } from "./session-catalog-identity.js";
@@ -102,6 +103,15 @@ function projectContentItem(
role: unknown,
value: unknown,
): SessionCatalogTranscriptItem | undefined {
const record = asOptionalRecord(value);
const inline =
role !== "user" &&
(readPluginInlineViewBlock(value) ??
readPluginInlineViewBlock(asOptionalRecord(record?.details)?.pluginView));
if (inline) {
// The catalog is an explicitly text-only transfer: never forward component data or authority.
return { type: "agentMessage", ...boundedText(inline.summary) };
}
if (role !== "user" && role !== "assistant") {
return undefined;
}
@@ -140,7 +150,8 @@ function projectMessageItems(
const content = Array.isArray(message.content)
? message.content
: [message.content ?? message.text];
return content
const inline = readPluginInlineViewBlock(asOptionalRecord(message.details)?.pluginView);
return (inline ? [inline] : content)
.flatMap((block, index) => {
const item = projectContentItem(message.role, block);
if (!item) {
@@ -203,10 +214,10 @@ async function readCatalogPageFromEntry(
storePath,
};
const scopeHash = createHash("sha256")
// Item offsets from the earlier tool-inclusive projection cannot resume this view.
// Item offsets from earlier projections (without inline summaries) cannot resume this view.
.update(
JSON.stringify([
"conversation",
"conversation-inline",
params.agentId,
params.sessionKey,
entry.sessionId,
+17
View File
@@ -117,6 +117,8 @@ export type ControlUiViewContext<T = Readonly<Record<string, string>>> = {
readonly host: ControlUiHost;
readonly signal: AbortSignal;
readonly props: T;
/** Report a fatal mount failure; the host disposes this instance and offers Retry. */
reportError?: (error: unknown) => void;
/** Presentation can pause a retained view without ending its host lifetime. */
readonly presented: boolean;
/** Mount the host's built-in view inside a replacement; it keeps receiving host updates. */
@@ -160,6 +162,20 @@ export type ControlUiNavigationItem = {
actions?: ControlUiNavigationAction[];
};
/** A predefined installed renderer. Payload is data, never a generated template. */
type ControlUiInlineView = {
id: string;
/** Exact component/payload interface version (1–65535), not the package version. */
version: number;
mount: ControlUiView<
BoardGetParams & {
payload: import("../plugins/host-hook-json.js").PluginJsonValue;
toolCallId: string;
toolName: string;
}
>;
};
export type ControlUiPanel = {
id: string;
label: string;
@@ -304,6 +320,7 @@ export type ControlUiHost = {
registerAction: (action: ControlUiAction) => ControlUiDisposer;
registerAccessory: (accessory: ControlUiAccessory) => ControlUiDisposer;
registerWidget: (widget: ControlUiWidget) => ControlUiDisposer;
registerInlineView: (view: ControlUiInlineView) => ControlUiDisposer;
registerReplacement: (replacement: ControlUiReplacement) => ControlUiDisposer;
/** Select an owned replacement, or restore the built-in view for this surface. */
selectReplacement: (surface: ControlUiSurface, id: string | null) => void;
+3
View File
@@ -4,6 +4,7 @@ import {
attachPluginApiFacades,
type OpenClawPluginApiWithoutFacades,
} from "../plugins/api-facades.js";
import { createPluginInlineViewResult } from "../plugins/inline-view.js";
import { createPluginServiceScheduler } from "../plugins/service-scheduler.js";
import type { PluginServiceSchedulerV1 } from "../plugins/service-scheduler.types.js";
import { createTestGatewayScheduler } from "../test-utils/gateway-scheduler-clock.js";
@@ -24,6 +25,8 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
const { agent, lifecycle, runContext, session, ...flatApi } = api;
const mergedApi = {
id: "test-plugin",
inlineView: (viewId, payload, options) =>
createPluginInlineViewResult(api.id ?? "test-plugin", viewId, payload, options),
name: "test-plugin",
source: "test",
registrationMode: "full",
+4 -1
View File
@@ -1,10 +1,11 @@
import { attachPluginApiFacades, type OpenClawPluginApiWithoutFacades } from "./api-facades.js";
import { createPluginInlineViewResult } from "./inline-view.js";
import type { PluginRuntime } from "./runtime/types.js";
import type { OpenClawPluginApi } from "./types.js";
type BuildPluginApiParams = Omit<
OpenClawPluginApiWithoutFacades,
keyof typeof noops | "registerNodeCliFeature" | "runtimeSource"
keyof typeof noops | "registerNodeCliFeature" | "runtimeSource" | "inlineView"
> & {
runtimeSource?: string;
handlers?: Partial<Pick<OpenClawPluginApiWithoutFacades, keyof typeof noops>>;
@@ -126,6 +127,8 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi
) as Pick<OpenClawPluginApiWithoutFacades, keyof typeof noops>;
const api: OpenClawPluginApiWithoutFacades = {
id: params.id,
inlineView: (viewId, payload, options) =>
createPluginInlineViewResult(params.id, viewId, payload, options),
name: params.name,
version: params.version,
description: params.description,
+7 -2
View File
@@ -96,7 +96,6 @@ type PluginControlUiTabGroup = "control" | "agent";
export type PluginControlUiDescriptor = {
id: string;
/** "tab" adds a sidebar tab; "widget" advertises a trusted dashboard renderer. */
surface: "session" | "tool" | "run" | "settings" | "tab" | "widget" | "link-reader";
/** Required for link-reader surfaces; passive models rendered by the host, never plugin JS. */
linkReader?: ControlUiLinkReaderMetadata;
label: string;
@@ -118,7 +117,13 @@ export type PluginControlUiDescriptor = {
group?: PluginControlUiTabGroup;
/** Sort order among plugin tabs; lower renders first. */
order?: number;
};
} & (
| { surface: "inline"; version: number }
| {
surface: "session" | "tool" | "run" | "settings" | "tab" | "widget" | "link-reader";
version?: never;
}
);
export type PluginSessionActionContext = {
pluginId: string;
+302
View File
@@ -0,0 +1,302 @@
import assert from "node:assert/strict";
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { projectMcpCallToolResult } from "../agents/mcp-content.js";
import { ToolSearchRuntime } from "../agents/tool-search-runtime.js";
import {
createToolSearchCatalogRef,
registerHeadlessToolSearchCatalog,
resolveToolSearchConfig,
} from "../agents/tool-search.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import {
clearRuntimeConfigSnapshot,
getRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "../config/runtime-snapshot.js";
import { projectChatDisplayMessagesWithState } from "../gateway/chat-display-projection.core.js";
import { projectPluginInlineView } from "../gateway/chat-display-projection.inline-view.js";
import { sanitizeChatHistoryMessage } from "../gateway/chat-display-projection.sanitize.js";
import { listControlUiPluginDescriptors } from "../gateway/control-ui-plugin-tabs.js";
import { prepareAgentWirePayload } from "../gateway/server-chat-live-text.js";
import { createChatRunState } from "../gateway/server-chat-state.js";
import { createPluginRuntimeMock } from "../plugin-sdk/test-helpers/plugin-runtime-mock.js";
import { createNestedToolActivity } from "../sessions/nested-tool-activity.js";
import { createTestPluginRegistry } from "./registry-runtime.test-helpers.js";
import { withPluginRuntimeRegistryScope } from "./runtime/gateway-request-scope.js";
import { createPluginRecord } from "./status.test-helpers.js";
import {
bindPluginToolCallbacks,
createPluginToolFactoryResolver,
} from "./tool-factory-runtime.js";
function fixture(version = 1, frozenProxy = false) {
const builder = createTestPluginRegistry(createPluginRuntimeMock());
const record = createPluginRecord({
id: "incidents",
name: "Incident tools",
origin: "bundled",
contracts: { tools: ["show_incident"] },
});
builder.registry.plugins.push(record);
const api = builder.createApi(record, { config: {} });
api.session.controls.registerControlUiDescriptor({
id: "incident",
surface: "inline",
label: "Incident dashboard",
version,
});
const source: AnyAgentTool = {
name: "show_incident",
label: "Show incident",
description: "Show an incident",
parameters: Type.Object({}),
execute: async () =>
api.inlineView(
"incident",
{ incidentId: "INC-42" },
{ version: 1, summary: "Showing incident INC-42." },
),
};
api.registerTool(frozenProxy ? new Proxy(Object.freeze(source), {}) : source);
const entry = builder.registry.tools[0]!;
// Resolve the admitted factory result, not the original plugin-owned object.
const { resolved } = createPluginToolFactoryResolver((error) => assert.fail(error)).resolve(
entry,
{},
[source.name],
);
assert(resolved && !Array.isArray(resolved));
const tool = bindPluginToolCallbacks(entry, builder.registry, resolved);
return { ...builder, api, tool };
}
describe("native inline tool results", () => {
it("binds frozen Proxy tools through registered factories and rejects retired calls", async () => {
const { registry, tool } = fixture(1, true);
const direct = await tool.execute("direct-proxy", {});
expect(asOptionalRecord(direct.details)?.pluginInlineViewInvocation).toEqual({
pluginId: "incidents",
toolName: "show_incident",
toolCallId: "direct-proxy",
});
const catalogRef = createToolSearchCatalogRef();
registerHeadlessToolSearchCatalog({ catalogRef, tools: [tool] });
const runtime = new ToolSearchRuntime({ catalogRef }, resolveToolSearchConfig());
const { result } = await runtime.call(tool.name, {}, { parentToolCallId: "proxy-parent" });
expect(asOptionalRecord(result.details)?.pluginInlineViewInvocation).toEqual({
pluginId: "incidents",
toolName: "show_incident",
toolCallId: "tool_call:proxy-parent:show_incident:1",
});
registry.plugins[0]!.enabled = false;
await expect(tool.execute("retired-proxy", {})).rejects.toThrow("no longer active");
});
it("advertises inline mounts only to the current viewer scopes and Labs policy", () => {
const { registry } = fixture();
const registration = registry.controlUiDescriptors[0]!;
registration.descriptor.requiredScopes = ["operator.write"];
const previous = getRuntimeConfigSnapshot();
try {
withPluginRuntimeRegistryScope(registry, () => {
expect(listControlUiPluginDescriptors(["operator.read"])).toEqual([]);
expect(listControlUiPluginDescriptors(["operator.write"])).toHaveLength(1);
registry.plugins[0]!.origin = "workspace";
setRuntimeConfigSnapshot({
gateway: { controlUi: { experimental: { customPlugins: false } } },
});
expect(listControlUiPluginDescriptors(["operator.admin"])).toEqual([]);
setRuntimeConfigSnapshot({
gateway: { controlUi: { experimental: { customPlugins: true } } },
});
expect(listControlUiPluginDescriptors(["operator.admin"])).toHaveLength(1);
});
} finally {
if (previous) {
setRuntimeConfigSnapshot(previous);
} else {
clearRuntimeConfigSnapshot();
}
}
});
it("binds canonical plugin identity and enforces JSON/UTF-8/summary bounds without rewriting data", () => {
const { api } = fixture();
const payload = { incidentId: "INC-42", text: "<script>text is plugin data</script>" };
const result = api.inlineView("incident", payload, {
version: 1,
summary: "Showing incident.",
});
expect(result.details.pluginInlineView).toEqual({
pluginId: "incidents",
viewId: "incident",
version: 1,
summary: "Showing incident.",
payload,
});
expect(result.details.pluginInlineView.payload).toBe(payload);
expect(
api.inlineView("incident", "a".repeat(16_382), { version: 1, summary: "Exact limit." }),
).toBeDefined();
for (const value of [
"a".repeat(16_383),
"😀".repeat(4096),
Number.NaN,
{ invalid: undefined },
{
toJSON() {
return "hidden";
},
},
]) {
expect(() =>
api.inlineView("incident", value as never, {
version: 1,
summary: "Too large or not JSON.",
}),
).toThrow("plain JSON");
}
expect(() => api.inlineView("incident", {}, { version: 1, summary: " " })).toThrow("summary");
expect(() => api.inlineView("incident", {}, { version: 1, summary: "a".repeat(4097) })).toThrow(
"4 KiB",
);
expect(() =>
api.inlineView("incident", {}, { version: 1, summary: "x" + "\n".repeat(2048) }),
).toThrow("4 KiB");
});
it.each([0, -1, 65536, 1.5, Number.NaN, undefined])(
"rejects missing or invalid version %j",
(version) => {
const { api, registry } = fixture(version === undefined ? 0 : version);
expect(registry.controlUiDescriptors).toEqual([]);
expect(() =>
api.inlineView("incident", {}, { version: version!, summary: "Fallback." }),
).toThrow("version");
},
);
it("registers explicit older versions alongside newer interfaces", () => {
const { api, registry } = fixture();
api.session.controls.registerControlUiDescriptor({
id: "incident",
surface: "inline",
version: 2,
label: "Incident v2",
});
api.session.controls.registerControlUiDescriptor({
id: "incident",
surface: "inline",
version: 1,
label: "Duplicate",
});
expect(registry.controlUiDescriptors.map((entry) => entry.descriptor.version)).toEqual([1, 2]);
});
it("projects direct live and persisted results identically without changing the stored envelope", async () => {
const { registry, tool } = fixture();
const result = await tool.execute("direct-1", {});
const raw = JSON.stringify(result);
const message = {
role: "toolResult",
toolName: tool.name,
toolCallId: "direct-1",
...result,
timestamp: 100,
};
withPluginRuntimeRegistryScope(registry, () => {
const history = asOptionalRecord(sanitizeChatHistoryMessage(message).message);
const live = prepareAgentWirePayload(
{
runId: "run",
seq: 1,
ts: 100,
stream: "tool",
data: { phase: "result", name: tool.name, toolCallId: "direct-1", result },
},
"run",
createChatRunState(),
() => true,
);
const expected = {
type: "plugin_view",
pluginId: "incidents",
viewId: "incident",
toolCallId: "direct-1",
toolName: "show_incident",
summary: "Showing incident INC-42.",
version: 1,
payload: { incidentId: "INC-42" },
};
expect(asOptionalRecord(history?.details)?.pluginView).toEqual(expected);
expect(asOptionalRecord(asOptionalRecord(live.data.result)?.details)?.pluginView).toEqual(
expected,
);
});
expect(JSON.stringify(result)).toBe(raw);
});
it("keeps a real Tool Search child identity and summary through snapshot and nested history projection", async () => {
const { registry, tool } = fixture();
const catalogRef = createToolSearchCatalogRef();
registerHeadlessToolSearchCatalog({ catalogRef, tools: [tool] });
const runtime = new ToolSearchRuntime({ catalogRef }, resolveToolSearchConfig());
const { result } = await runtime.call(tool.name, {}, { parentToolCallId: "outer-exec" });
const owner = asOptionalRecord(asOptionalRecord(result.details)?.pluginInlineViewInvocation)!;
expect(owner.toolCallId).toBe("tool_call:outer-exec:show_incident:1");
const activity = createNestedToolActivity({
runId: "run",
scopeId: "scope",
afterEntryId: null,
startOrder: 1,
parentToolCallId: "outer-exec",
toolCallId: String(owner.toolCallId),
toolName: tool.name,
input: {},
result,
isError: false,
startedAt: 100,
timestamp: 110,
});
withPluginRuntimeRegistryScope(registry, () => {
const projected = projectChatDisplayMessagesWithState([activity]).messages;
expect(JSON.stringify(projected)).toContain('"type":"plugin_view"');
expect(JSON.stringify(projected)).toContain('"summary":"Showing incident INC-42."');
expect(
projectPluginInlineView(result.details, { toolCallId: "outer-exec", toolName: "exec" }),
).toBeUndefined();
});
});
it("preserves the entire record without a renderer and never admits unbound MCP data", async () => {
const original = fixture();
const result = await original.tool.execute("one", {});
const invocation = { toolCallId: "one", toolName: "show_incident" };
const wire = JSON.stringify(result);
const saved = JSON.parse(wire);
const empty = createTestPluginRegistry().registry;
withPluginRuntimeRegistryScope(empty, () => {
expect(projectPluginInlineView(saved.details, invocation)).toEqual({
type: "plugin_view",
pluginId: "incidents",
viewId: "incident",
version: 1,
payload: { incidentId: "INC-42" },
summary: "Showing incident INC-42.",
...invocation,
});
});
expect(
projectPluginInlineView(
original.api.inlineView("incident", {}, { version: 1, summary: "Unbound." }).details,
invocation,
),
).toBeUndefined();
const mcp = projectMcpCallToolResult({
content: [{ type: "text", text: JSON.stringify(result) }],
structuredContent: result.details,
});
expect(projectPluginInlineView(mcp.details, invocation)).toBeUndefined();
});
});
+79
View File
@@ -0,0 +1,79 @@
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import type { AgentToolResult } from "../agents/runtime/index.js";
import {
PLUGIN_INLINE_VIEW_MAX_BYTES,
isPluginInlineViewVersion,
readPluginInlineViewDescriptor,
type PluginInlineViewDescriptor,
} from "../shared/plugin-inline-view.js";
import { isPluginJsonValue, type PluginJsonValue } from "./host-hook-json.js";
export type PluginInlineViewResult = {
content: [{ type: "text"; text: string }];
details: { pluginInlineView: PluginInlineViewDescriptor };
};
export function isInlineViewPayload(value: unknown): value is PluginJsonValue {
return (
isPluginJsonValue(value) &&
Buffer.byteLength(JSON.stringify(value), "utf8") <= PLUGIN_INLINE_VIEW_MAX_BYTES
);
}
export function createPluginInlineViewResult(
pluginId: string,
viewId: string,
payload: PluginJsonValue,
options: { version: number; summary: string },
): PluginInlineViewResult {
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(viewId)) {
throw new Error("Inline view requires a registered view id.");
}
if (!isPluginInlineViewVersion(options.version)) {
throw new Error("Inline view version must be an integer from 1 to 65535.");
}
if (typeof options.summary !== "string" || !options.summary.trim()) {
throw new Error("Inline view requires a nonblank text summary.");
}
if (Buffer.byteLength(JSON.stringify(options.summary), "utf8") > 4096) {
throw new Error("Inline view summary must be at most 4 KiB of serialized UTF-8 JSON.");
}
if (!isInlineViewPayload(payload)) {
throw new Error(
"Inline view payload must be plain JSON of at most 16 KiB UTF-8. Store larger data in the plugin and pass an id.",
);
}
return {
content: [{ type: "text", text: options.summary }],
details: {
pluginInlineView: {
pluginId,
viewId,
version: options.version,
payload,
summary: options.summary,
},
},
};
}
/** Bind display provenance at the native tool owner, before direct/nested transcript capture. */
export function bindPluginInlineViewInvocation<T extends AgentToolResult<unknown>>(
result: T,
pluginId: string,
toolName: string,
toolCallId: string,
): T {
const details = asOptionalRecord(result.details);
const descriptor = readPluginInlineViewDescriptor(details?.pluginInlineView);
if (!descriptor || descriptor.pluginId !== pluginId) {
return result;
}
return {
...result,
details: {
...details,
pluginInlineViewInvocation: { pluginId, toolName, toolCallId },
},
};
}
+6
View File
@@ -214,6 +214,12 @@ type OpenClawPluginLifecycleApi = Partial<
* @see https://docs.openclaw.ai/plugins/sdk-overview#api-stability
*/
export type OpenClawPluginApi = {
/** Data for an installed native view, with a portable text summary. */
inlineView: (
viewId: string,
payload: import("./host-hook-json.js").PluginJsonValue,
options: { version: number; summary: string },
) => import("./inline-view.js").PluginInlineViewResult;
id: string;
name: string;
version?: string;
+23 -5
View File
@@ -2,6 +2,7 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { classifyGatewayProbePath } from "../gateway/gateway-http-route-contracts.js";
import { isOperatorScope } from "../gateway/operator-scopes.js";
import type { ControlUiLinkReaderMetadata } from "../shared/control-ui-link-reader.js";
import { isPluginInlineViewVersion } from "../shared/plugin-inline-view.js";
import {
isPluginJsonValue,
normalizeHostHookString,
@@ -81,6 +82,7 @@ const controlUiSurfaces = new Set<PluginControlUiDescriptor["surface"]>([
"tab",
"widget",
"link-reader",
"inline",
]);
const reservedTabSlugs = new Set([
"api",
@@ -179,8 +181,20 @@ export function createControlUiRegistrar(state: PluginRegistryState) {
);
return;
}
if (surface === "inline" && !isPluginInlineViewVersion(descriptor.version)) {
reportRegistrationError(
record,
"Inline view version must be an integer from 1 to 65535: " + id,
);
return;
}
const existing = registry.controlUiDescriptors.find(
(entry) => entry.pluginId === record.id && entry.descriptor.id === id,
(entry) =>
entry.pluginId === record.id &&
entry.descriptor.id === id &&
(surface !== "inline" ||
entry.descriptor.surface !== "inline" ||
entry.descriptor.version === descriptor.version),
);
if (existing) {
reportRegistrationError(record, `control UI descriptor already registered: ${id}`);
@@ -222,9 +236,11 @@ export function createControlUiRegistrar(state: PluginRegistryState) {
? "page"
: surface === "widget"
? "widget"
: surface === "link-reader"
? "link-reader"
: undefined;
: surface === "inline"
? "inline"
: surface === "link-reader"
? "link-reader"
: undefined;
// Missing declarations are advisory: metadata never grants or denies a UI registration.
if (capability && record.uiCapabilities && !record.uiCapabilities.includes(capability)) {
state.reportRegistrationWarning(
@@ -238,7 +254,9 @@ export function createControlUiRegistrar(state: PluginRegistryState) {
descriptor: {
...descriptor,
id,
surface,
...(descriptor.surface === "inline"
? { surface: descriptor.surface, version: descriptor.version }
: { surface: descriptor.surface ?? "session", version: undefined }),
...(linkReader ? { linkReader } : {}),
label,
...(description !== undefined ? { description } : {}),
+5 -1
View File
@@ -431,7 +431,11 @@ export type PluginRegistry = {
>;
trustedToolPolicies: PluginTrustedToolPolicyRegistryRegistration[];
toolMetadata: Array<PluginRegistrationOwner & { metadata: PluginToolMetadataRegistration }>;
controlUiDescriptors: Array<PluginRegistrationOwner & { descriptor: PluginControlUiDescriptor }>;
controlUiDescriptors: Array<
PluginRegistrationOwner & {
descriptor: PluginControlUiDescriptor;
}
>;
runtimeLifecycles: Array<
PluginRegistrationOwner & { lifecycle: PluginRuntimeLifecycleRegistration }
>;
@@ -10,9 +10,14 @@ import {
} from "../config/io.js";
import { appendTranscriptMessage } from "../config/sessions/session-accessor.js";
import { upsertSessionEntry } from "../plugin-sdk/session-store-runtime.js";
import { readVisibleSessionTranscriptMessageEntries as readMessages } from "../plugin-sdk/session-transcript-runtime.js";
import {
readSessionTranscriptCatalogPage,
readVisibleSessionTranscriptMessageEntries as readMessages,
} from "../plugin-sdk/session-transcript-runtime.js";
import { withTempHome } from "../plugin-sdk/test-env.js";
import * as externalContent from "../security/external-content.js";
import { createNestedToolActivity } from "../sessions/nested-tool-activity.js";
import { createPluginInlineViewResult, bindPluginInlineViewInvocation } from "./inline-view.js";
import {
importSessionCatalogHistory,
preserveSessionCatalogHistory,
@@ -220,3 +225,105 @@ it("preserves items once across sync, including id-less duplicates and a shifted
expect(texts.at(-1)).toContain("New source answer");
});
});
it("preserves inline fallback through native storage, text catalog and untrusted import", async () => {
await withTempHome(async (home) => {
const { scope, config } = catalogStore(home, "inline-source");
const previous = getRuntimeConfigSnapshot();
setRuntimeConfigSnapshot(config);
try {
await initializeStore(scope);
const result = bindPluginInlineViewInvocation(
createPluginInlineViewResult(
"incidents",
"incident",
{ incidentId: "INC-42" },
{ version: 3, summary: "INC-42 is investigating." },
),
"incidents",
"show_incident",
"call-1",
);
await appendTranscriptMessage(scope, {
message: {
role: "toolResult",
toolName: "show_incident",
toolCallId: "call-1",
...result,
__openclaw: { runId: "run-1" },
timestamp: 1000,
},
});
const nested = bindPluginInlineViewInvocation(
createPluginInlineViewResult(
"incidents",
"incident",
{ incidentId: "INC-43" },
{ version: 3, summary: "INC-43 is resolved." },
),
"incidents",
"show_incident",
"call-1",
);
await appendTranscriptMessage(scope, {
message: createNestedToolActivity({
runId: "run-2",
scopeId: "nested",
afterEntryId: null,
startOrder: 1,
parentToolCallId: "outer",
toolCallId: "call-1",
toolName: "show_incident",
input: {},
result: nested,
isError: false,
startedAt: 2000,
timestamp: 2100,
}),
});
const stored = await readMessages(scope);
expect(stored[0]).toMatchObject({
message: { details: result.details, __openclaw: { runId: "run-1" } },
});
const catalog = await readSessionTranscriptCatalogPage({
...scope,
sourceDomain: "source",
pluginId: "catalog",
limit: 10,
});
expect(catalog.items.map((item) => [item.type, item.text])).toEqual([
["agentMessage", "INC-43 is resolved."],
["agentMessage", "INC-42 is investigating."],
]);
const target = catalogStore(home, "inline-destination");
await initializeStore(target.scope);
await importSessionCatalogHistory({
...target.scope,
config: target.config,
catalogId: "external",
threadId: "incident",
read: catalogReader(
catalog.items.toReversed().map((item) => ({
type: item.type,
text: item.text,
raw: result.details,
})),
),
});
const imported = await readMessages(target.scope);
expect(imported[0]?.message).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "INC-42 is investigating." }],
});
expect(JSON.stringify(imported)).not.toContain("pluginInlineView");
expect(JSON.stringify(imported)).not.toContain("plugin_view");
expect(imported).toHaveLength(2);
} finally {
if (previous) {
setRuntimeConfigSnapshot(previous);
} else {
clearRuntimeConfigSnapshot();
}
}
});
});
+3 -1
View File
@@ -6,6 +6,7 @@ import { racePromiseWithAbortSignal } from "../infra/abort-signal.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { runWithTrackedCancellation } from "../shared/async-work-resources.js";
import { bindPluginInlineViewInvocation } from "./inline-view.js";
import { prepareMemoryAudienceRead } from "./memory-audience.js";
import { resolveMemoryCapabilityRegistration } from "./memory-state.js";
import { capturePluginLifecycleAuthority } from "./registry-lifecycle.js";
@@ -79,7 +80,7 @@ export function bindPluginToolCallbacks(
await racePromiseWithAbortSignal(pending, signal);
}
// Cancellation retains this context after the instance call has settled.
return withPluginRuntimePluginScope(
const result = await withPluginRuntimePluginScope(
{ pluginId: entry.pluginId, pluginSource: entry.source },
() =>
invoke(() => {
@@ -89,6 +90,7 @@ export function bindPluginToolCallbacks(
}),
registry,
);
return bindPluginInlineViewInvocation(result, entry.pluginId, tool.name, toolCallId);
},
...(prepare
? { prepareArguments: (args: unknown) => invoke(() => prepare.call(tool, args)) }
+58
View File
@@ -0,0 +1,58 @@
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import type { PluginJsonValue } from "../plugins/host-hook-json.js";
export const PLUGIN_INLINE_VIEW_MAX_BYTES = 16 * 1024;
/** Component/payload interface version, independent of the plugin package version. */
export function isPluginInlineViewVersion(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
}
/** The durable display record. Availability is presentation state, never stored data. */
export type PluginInlineViewDescriptor = {
pluginId: string;
viewId: string;
version: number;
payload: PluginJsonValue;
summary: string;
};
export type PluginInlineViewBlock = PluginInlineViewDescriptor & {
type: "plugin_view";
toolCallId: string;
toolName: string;
};
/** Read the envelope; native tool admission owns bounded JSON validation. */
export function readPluginInlineViewDescriptor(
value: unknown,
): PluginInlineViewDescriptor | undefined {
const record = asOptionalRecord(value);
if (
!record ||
!["pluginId", "viewId", "summary"].every(
(key) => typeof record[key] === "string" && record[key].trim().length > 0,
) ||
!isPluginInlineViewVersion(record.version) ||
!("payload" in record)
) {
return undefined;
}
// SAFETY: the envelope is checked above; host admission owns the JSON payload contract.
return record as PluginInlineViewDescriptor;
}
/** Only the structured host projection is display input; never parse text or guest JSON. */
export function readPluginInlineViewBlock(value: unknown): PluginInlineViewBlock | undefined {
const block = asOptionalRecord(value);
if (
block?.type !== "plugin_view" ||
!readPluginInlineViewDescriptor(block) ||
!["toolCallId", "toolName"].every(
(key) => typeof block[key] === "string" && block[key].trim().length > 0,
)
) {
return undefined;
}
// SAFETY: the display envelope and invocation fields were checked above.
return block as PluginInlineViewBlock;
}
@@ -575,6 +575,36 @@ describe("Control UI performance budgets", () => {
}
});
it.each([
{ baselineBytes: 315_881, limit: 316_457 },
{ baselineBytes: 311_544, limit: 312_120 },
])(
"enforces unchanged startup allowances for a $baselineBytes B baseline",
({ baselineBytes, limit }) => {
const fixture = createCliFixture();
// Synthetic assets and their baseline must not depend on the live build ratchet.
fs.writeFileSync(fixture.baselinePath, JSON.stringify(startupBaseline(baselineBytes)));
const assetsDir = path.join(fixture.distDir, "assets");
writeAssetAt(assetsDir, "index-a.js", 200_000, 200_000, 200_000);
writeAssetAt(assetsDir, "runtime-b.js", limit - 200_000);
fs.appendFileSync(path.join(fixture.distDir, "index.html"), preload("runtime-b.js"));
expect(jsonCli(fixture)).toMatchObject({
budgets: { startupJsGzipBytes: 325_131 },
startupBudgetBaseline: { startupJsGzipBytes: baselineBytes },
startupJsTolerance: 512,
startupJsBuildVariance: 64,
metrics: { startup: { js: { gzipBytes: limit, requests: 2 } } },
violations: [],
});
fs.appendFileSync(path.join(assetsDir, "index-a.js.gz"), "x");
expect(jsonCli(fixture, [], 1).violations).toEqual([
{ metric: "startup JS gzip", actual: limit + 1, limit, unit: "bytes" },
]);
},
);
it("updates the baseline from generated or explicitly measured metrics", () => {
const { run, baselinePath, distDir } = createCliFixture();
const update = (args: string[] = []) => {
+1 -1
View File
@@ -34,7 +34,7 @@ const qaLabFiles = [
] as const;
const serialGatewayFiles =
`approval-lookup chat-steer-deferred-custody deepseek-search desktop-resize
peer-followup-restart`
peer-followup-restart plugin-inline-view`
.split(/\s+/u)
.map((name) => `ui/src/e2e/${name}.real-gateway.e2e.test.ts`)
.concat("ui/src/e2e/mcp-app-conformance.e2e.test.ts");
+1
View File
@@ -78,6 +78,7 @@ export const uiE2ePrivateServerTestFiles = [
"ui/src/e2e/native-notifications-loading.e2e.test.ts",
"ui/src/e2e/new-session-page.cloud-startup.runtime-load.e2e.test.ts",
"ui/src/e2e/peer-followup-restart.real-gateway.e2e.test.ts",
"ui/src/e2e/plugin-inline-view.real-gateway.e2e.test.ts",
"ui/src/e2e/post-deploy-session-entry.e2e.test.ts",
"ui/src/e2e/presentation-primitives.e2e.test.ts",
"ui/src/e2e/provider-browser-login.real-gateway.e2e.test.ts",
+1
View File
@@ -139,6 +139,7 @@ export function isUiTestTarget(relative) {
export const uiE2eRealGatewayTestFiles = [
"ui/src/e2e/approval-lookup.real-gateway.e2e.test.ts",
"ui/src/e2e/plugin-inline-view.real-gateway.e2e.test.ts",
"ui/src/e2e/deepseek-search.real-gateway.e2e.test.ts",
"ui/src/e2e/peer-followup-restart.real-gateway.e2e.test.ts",
"ui/src/e2e/chat-session-diff-folding.real-gateway.e2e.test.ts",
+5
View File
@@ -277,6 +277,7 @@
"src/shared/model-key.ts",
"src/shared/model-runtime-route.ts",
"src/shared/operator-scope-compat.ts",
"src/shared/plugin-inline-view.ts",
"src/shared/presence-user.ts",
"src/shared/regexp.ts",
"src/shared/session-activity-timestamp.ts",
@@ -1375,6 +1376,7 @@
"ui/src/lib/chat/message-normalizer.ts",
"ui/src/lib/chat/message-visibility.ts",
"ui/src/lib/chat/outbox-recovery.ts",
"ui/src/lib/chat/plugin-inline-views.ts",
"ui/src/lib/chat/source-previews.ts",
"ui/src/lib/chat/tool-approval-reviews.ts",
"ui/src/lib/chat/tool-call-diff.ts",
@@ -1555,6 +1557,7 @@
"ui/src/pages/chat/chat-system-event-grouping.ts",
"ui/src/pages/chat/chat-system-notice.ts",
"ui/src/pages/chat/chat-thread-build.ts",
"ui/src/pages/chat/chat-thread-canvas.ts",
"ui/src/pages/chat/chat-thread-duplicates.ts",
"ui/src/pages/chat/chat-thread-grouping-cache.ts",
"ui/src/pages/chat/chat-thread-grouping.ts",
@@ -1819,6 +1822,8 @@
"ui/src/pages/labs/labs-registry.ts",
"ui/src/pages/plugins/plugin-icon-owner.ts",
"ui/src/plugins/control-ui-actions.ts",
"ui/src/plugins/control-ui-disabled.solid.tsx",
"ui/src/plugins/control-ui-inline-view.tsx",
"ui/src/styles/chat/attachments.css",
"ui/src/styles/chat/board.css",
"ui/src/styles/chat/composer-mobile.css",
@@ -78,6 +78,7 @@ async function fixture() {
actions: [],
accessories: [],
widgets: [],
inlineViews: [],
replacements: [],
};
const plugins = Object.assign(context.plugins, {
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
import { afterEach, describe, expect, it, onTestFailed, onTestFinished, vi } from "vitest";
import {
createGatewayHarness,
createSessions,
@@ -10,6 +10,7 @@ import {
createTestGatewayClient,
} from "../test-helpers/gateway-client.ts";
import "../test-helpers/load-styles.ts";
import { registryFor } from "./overlay-registry.ts";
afterEach(() => document.body.replaceChildren());
@@ -34,6 +35,15 @@ describe.runIf("__vitest_browser__" in globalThis)("navigation rail menus", () =
sidebar.hostElement.style.cssText = "display:block;width:340px;height:880px";
sidebar.connected = true;
await sidebar.updateComplete;
const events: Record<string, unknown>[] = [];
onTestFailed(() => console.error("[rail-menu-escape]", JSON.stringify(events)));
const identify = (target: EventTarget | null) =>
target instanceof Element
? (target.tagName + "#" + target.id + "." + (target.getAttribute("class") ?? "")).slice(
0,
200,
)
: null;
const rail = sidebar.querySelector<HTMLElement>(".sidebar-rail")!;
expect(rail).not.toBeNull();
for (const [triggerSelector, panelSelector, edge] of [
@@ -72,10 +82,78 @@ describe.runIf("__vitest_browser__" in globalThis)("navigation rail menus", () =
expect(panelBox.bottom).toBeLessThanOrEqual(triggerBox.bottom + 1);
}
expect(panel()!.contains(document.activeElement)).toBe(true);
await userEvent.keyboard("{Escape}");
await sidebar.updateComplete;
expect(panel()).toBeNull();
expect(document.activeElement).toBe(trigger);
const surface = panel()!;
const registry = registryFor(document);
const overlay = [...registry.members.keys()].find((entry) => entry.surface === surface);
let escape: KeyboardEvent | undefined;
const record = (stage: string, detail: Record<string, unknown> = {}) => {
const top = registry.stack.at(-1);
if (events.length === 64) {
events.shift();
}
events.push({
stage,
menu: panelSelector,
at: performance.now(),
trigger: identify(trigger),
panel: identify(surface),
present: panel() === surface,
connected: surface.isConnected,
phase: surface.dataset.phase,
open: overlay?.open,
agentMenuOpen: sidebar.sidebarMenus.agentMenuPosition !== null,
hasFocus: document.hasFocus(),
active: identify(document.activeElement),
top: top && {
id: top.id,
open: top.open,
phase: top.phase,
connected: top.surface.isConnected,
},
...detail,
});
};
const keydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
escape = event;
record("escape-capture", {
target: identify(event.target),
trusted: event.isTrusted,
composing: event.isComposing,
defaultPrevented: event.defaultPrevented,
});
}
};
const lifecycle = (event: Event) => record(event.type);
const lifecycleEvents = ["overlay-hide", "beforetoggle", "toggle"] as const;
const unsubscribe = overlay?.subscribe((open) => record("accepted-open", { open }));
document.addEventListener("keydown", keydown, { capture: true, passive: true });
for (const type of lifecycleEvents) {
surface.addEventListener(type, lifecycle, { passive: true });
}
const stop = () => {
document.removeEventListener("keydown", keydown, true);
lifecycleEvents.forEach((type) => surface.removeEventListener(type, lifecycle));
unsubscribe?.();
escape = undefined;
};
onTestFinished(stop);
try {
record("before-keyboard");
await userEvent.keyboard("{Escape}");
record("after-keyboard", {
escapeReceived: Boolean(escape),
defaultPrevented: escape?.defaultPrevented,
});
escape = undefined;
await sidebar.updateComplete;
record("after-sidebar-update");
expect(panel()).toBeNull();
expect(document.activeElement).toBe(trigger);
} finally {
// Freeze live-state evidence before afterEach removes the fixture; failure hooks run later.
stop();
}
}
});
});
@@ -255,9 +255,10 @@ suite.define(() => {
await logout.click();
await expectRequests(gateway, "channels.logout", 0);
const firstConfirm = (await waitForConfirmModal(page)).filter({
const logoutConfirmation = page.locator("openclaw-modal-dialog").filter({
has: page.getByText("Log out of WhatsApp account default?", { exact: true }),
});
const firstConfirm = await waitForConfirmModal(page, logoutConfirmation);
const confirmation = await firstConfirm.textContent();
expect(confirmation).toContain("Log out of WhatsApp account default?");
expect(confirmation).toContain(
@@ -272,9 +273,7 @@ suite.define(() => {
await expect.poll(() => field(detail, "Linked").textContent()).toContain("Yes");
await logout.click();
const secondConfirm = (await waitForConfirmModal(page)).filter({
has: page.getByText("Log out of WhatsApp account default?", { exact: true }),
});
const secondConfirm = await waitForConfirmModal(page, logoutConfirmation);
await button(secondConfirm, "Logout").click();
await expect
.poll(async () => detail.locator(".settings-row__desc").allTextContents())
@@ -346,9 +345,12 @@ suite.define(() => {
async (page, gateway) => {
const detail = await openChannel(page);
await detail.getByRole("button", { name: "Logout" }).click();
const confirm = (await waitForConfirmModal(page)).filter({
has: page.getByText("Log out of WhatsApp account work?", { exact: true }),
});
const confirm = await waitForConfirmModal(
page,
page.locator("openclaw-modal-dialog").filter({
has: page.getByText("Log out of WhatsApp account work?", { exact: true }),
}),
);
await expect.poll(() => confirm.count()).toBe(1);
const socketCount = await gateway.getSocketCount();
await gateway.closeLatest(1012, "Reconnect during logout confirmation");
+1 -1
View File
@@ -591,7 +591,7 @@ suite.define(() => {
expect(await mountedCanvas?.evaluate((element) => element.isConnected)).toBe(true);
}
await page.setViewportSize({ width: 400, height: 900 });
await openChatPanels(page);
await openChatPanels(page, "mobile");
const host = page.locator(".chat-mobile-host");
await host.getByRole("button", { name: "Panels", exact: true }).click();
await side.locator('.tabstrip-tab[data-tab-value^="hosted:terminal:"]').click();
+3 -3
View File
@@ -740,7 +740,7 @@ suite.define(() => {
await page.keyboard.press("Escape");
await clear.waitFor({ state: "hidden" });
await gateway.resolveDeferred("sessions.companion.ask");
await openChatPanels(page);
await openChatPanels(page, "mobile");
await companion
.getByText("The mobile side chat stayed inside its panel.", { exact: true })
.waitFor();
@@ -757,7 +757,7 @@ suite.define(() => {
await captureRichPanel(page, "rails-side-chat-mobile-light");
await page.keyboard.press("Escape");
await clear.waitFor({ state: "hidden" });
await openChatPanels(page);
await openChatPanels(page, "mobile");
await companion.waitFor();
expect(await gateway.getRequests("sessions.companion.reset")).toHaveLength(resets);
await companion
@@ -777,7 +777,7 @@ suite.define(() => {
expect(reset.params).toEqual({ sessionKey, agentId: "main" });
expect(await gateway.getRequests("sessions.companion.reset")).toHaveLength(resets + 1);
await clear.waitFor({ state: "hidden" });
await openChatPanels(page);
await openChatPanels(page, "mobile");
await companion
.getByText("Answers come from this session's transcript and its project files.", {
exact: true,
@@ -5,7 +5,11 @@ import path from "node:path";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { takeControlUiViewportScreenshot } from "../test-helpers/control-ui-e2e-screenshot.ts";
import { installMockGateway, startControlUiE2eServer } from "../test-helpers/control-ui-e2e.ts";
import {
installMockGateway,
startControlUiE2eServer,
waitForConfirmModal,
} from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const basePath = "/control";
@@ -126,6 +130,7 @@ suite.define(() => {
expect(response?.status()).toBe(200);
const confirmation = page.locator("openclaw-gateway-url-confirmation");
await waitForConfirmModal(page, confirmation.locator("openclaw-modal-dialog"));
await confirmation.getByText(explicitGatewayUrl, { exact: true }).waitFor();
expect(await confirmation.getByText(explicitGatewayUrl, { exact: true }).count()).toBe(1);
await confirmation
@@ -81,7 +81,7 @@ async function withMobilePage(run: (page: Page) => Promise<void>, colorScheme?:
async function openMobilePanelLauncher(page: Page) {
await nativePane(page).locator(".agent-chat__header--mobile").waitFor();
const trigger = await openChatPanels(page);
const trigger = await openChatPanels(page, "mobile");
const host = page.locator(".chat-mobile-host").filter({ visible: true });
await host
.locator(".chat-mobile-host__header")
@@ -89,7 +89,10 @@ suite.define(() => {
await stop.waitFor({ state: "visible" });
const menuText = (await stop.textContent())?.trim();
await stop.click();
const dialog = await waitForConfirmModal(page, ".session-worker-stop");
const workerStopConfirmation = page.locator("openclaw-modal-dialog").filter({
has: page.locator(".session-worker-stop"),
});
const dialog = await waitForConfirmModal(page, workerStopConfirmation);
expect(menuText).toBe("Stop device worker…");
expect(
await dialog.getByRole("heading", { name: "Stop worker?", exact: true }).textContent(),
@@ -107,7 +110,7 @@ suite.define(() => {
await details.locator(".chat-environment__machine-trigger").click();
await stop.click();
await (
await waitForConfirmModal(page, ".session-worker-stop")
await waitForConfirmModal(page, workerStopConfirmation)
)
.getByRole("button", { name: "Stop worker", exact: true })
.click();
+325
View File
@@ -0,0 +1,325 @@
import { writeFileSync } from "node:fs";
import path from "node:path";
import { expect, it } from "vitest";
import type { PluginInlineViewBlock } from "../../../src/shared/plugin-inline-view.js";
import { takeControlUiScreenshotFrame } from "../test-helpers/control-ui-e2e-screenshot.ts";
import {
controlUiSessionUrl,
defaultControlUiFeatureMethods,
installMockGateway,
} from "../test-helpers/control-ui-e2e.ts";
import { prepareNativeControlUiPluginFixtures } from "../test-helpers/control-ui-plugin-fixture.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const suite = createControlUiE2eSuite({ name: "Native inline plugin views" });
const sessionKey = "agent:main:incident-review";
const incident = {
incidentId: "INC-42",
title: "Elevated checkout latency",
service: "Checkout API",
severity: "SEV 2",
status: "Investigating",
acknowledged: false,
};
const now = Date.now();
const summary = "INC-42: Elevated checkout latency — Investigating.";
const view = (toolCallId: string): PluginInlineViewBlock => ({
type: "plugin_view",
pluginId: "incident-tools",
viewId: "incident-dashboard",
toolName: "show_incident",
toolCallId,
summary,
version: 1,
payload: { incidentId: "INC-42" },
});
const toolResult = (toolCallId: string, timestamp: number) => ({
role: "toolResult",
toolName: "show_incident",
toolCallId,
content: [{ type: "text", text: summary }],
details: { pluginView: view(toolCallId) },
timestamp,
});
const descriptor = {
id: "incident-dashboard",
label: "Incident dashboard",
version: 1,
};
suite.define(() => {
it("filters inline views through the visible transcript search", async () => {
await suite.withPage(
{ viewport: { width: 1280, height: 900 }, colorScheme: "light", serviceWorkers: "block" },
async ({ page }) => {
const prompt = "Review the incident and release checklist.";
const match = "Release checklist: smoke tests passed; rollout is ready.";
const gateway = await installMockGateway(page, {
agentModel: "openai/gpt-4.1",
sessionKey,
historyMessages: [
{ role: "user", content: prompt, timestamp: now },
toolResult("search-incident", now + 1),
{ role: "assistant", content: match, timestamp: now + 2 },
],
nativePlugins: [
{
pluginId: "incident-tools",
rootDir: path.resolve("examples/inline-incident"),
source: "./control-ui.ts",
inlineViews: [descriptor],
},
],
methodResponses: { "incident-tools.get": incident },
});
await page.goto(controlUiSessionUrl(suite.server.baseUrl, sessionKey, "chat"));
const pane = page.locator(".chat-pane-cache__pane--active");
const inline = pane.locator('[data-plugin-inline-view="search-incident"]');
await inline.getByRole("heading", { name: incident.title }).waitFor();
const composer = pane.locator(".agent-chat__composer-combobox textarea");
await composer.focus();
const shortcut = await page.evaluate(() =>
/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "Meta+f" : "Control+f",
);
await page.keyboard.press(shortcut);
const search = pane.getByRole("textbox", { name: "Search messages", exact: true });
await search.fill("smoke tests");
await expect.poll(() => pane.getByText(prompt, { exact: true }).count()).toBe(0);
await pane.getByText(match, { exact: true }).waitFor();
if (process.env.OPENCLAW_CAPTURE_UI_PROOF === "1") {
const frame = await takeControlUiScreenshotFrame(
page,
search,
[pane.getByText(match, { exact: true })],
{ animations: "disabled" },
);
writeFileSync(path.join(suite.artifactDir, "search-filter.png"), frame.png);
}
expect(await inline.count()).toBe(0);
expect(await pane.getByText(summary, { exact: true }).count()).toBe(0);
await search.fill("INC-42");
await inline.getByRole("heading", { name: incident.title }).waitFor();
await search.fill(" ");
await inline.getByRole("heading", { name: incident.title }).waitFor();
await pane.getByText(match, { exact: true }).waitFor();
expect((await gateway.getRequests("chat.send")).length).toBe(0);
},
);
});
it("mounts during a live turn, shares panel state, and lazily reconstructs history without rerunning tools", async () => {
await suite.withPage(
{ viewport: { width: 1280, height: 900 }, serviceWorkers: "block" },
async ({ page }) => {
const initial = [
{
role: "user",
content: "Show the checkout incident and its current status.",
timestamp: now,
},
{
role: "assistant",
content:
"Here is the incident dashboard. You can acknowledge it or open the details alongside this conversation.",
timestamp: now + 100,
},
];
const { catalog } = await prepareNativeControlUiPluginFixtures([
{
pluginId: "incident-tools",
rootDir: path.resolve("examples/inline-incident"),
source: "./control-ui.ts",
inlineViews: [descriptor],
},
]);
const gateway = await installMockGateway(page, {
deferredMethods: ["plugins.controlUi.list"],
agentModel: "openai/gpt-4.1",
sessionKey,
historyMessages: initial,
inFlightRun: { runId: "incident-run", startedAt: now + 100, events: [] },
nativePlugins: [
{
pluginId: "incident-tools",
rootDir: path.resolve("examples/inline-incident"),
source: "./control-ui.ts",
inlineViews: [descriptor],
},
],
featureMethods: [
...defaultControlUiFeatureMethods,
"plugins.uiDescriptors",
"plugins.controlUi.list",
"plugins.controlUi.report",
],
methodResponses: {
"incident-tools.get": incident,
"incident-tools.acknowledge": { ...incident, acknowledged: true },
},
});
await page.goto(controlUiSessionUrl(suite.server.baseUrl, sessionKey, "chat"));
await page.getByText("Here is the incident dashboard.", { exact: false }).waitFor();
await gateway.emitGatewayEvent("agent", {
runId: "incident-run",
sessionKey,
agentId: "main",
seq: 1,
ts: now + 200,
stream: "tool",
data: {
phase: "start",
name: "show_incident",
toolCallId: "live-incident",
args: { incidentId: "INC-42" },
},
});
await gateway.emitGatewayEvent("agent", {
runId: "incident-run",
sessionKey,
agentId: "main",
seq: 2,
ts: now + 210,
stream: "tool",
data: {
phase: "result",
name: "show_incident",
toolCallId: "live-incident",
result: {
content: [{ type: "text", text: summary }],
details: { pluginView: view("live-incident") },
},
isError: false,
},
});
const inline = page.locator('[data-plugin-inline-view="live-incident"]');
await inline.getByText("Loading plugin view…").waitFor();
await gateway.resolveDeferred("plugins.controlUi.list", catalog);
await inline.getByRole("heading", { name: incident.title }).waitFor();
expect((await gateway.getRequests("chat.send")).length).toBe(0);
const order = await inline.evaluate((element) => {
const bubble = element.closest(".chat-bubble");
return bubble?.textContent?.indexOf("INC-42:") ?? -1;
});
expect(order).toBeGreaterThanOrEqual(0);
if (process.env.OPENCLAW_CAPTURE_UI_PROOF === "1") {
const frame = await takeControlUiScreenshotFrame(
page,
inline,
[inline.getByRole("heading", { name: incident.title })],
{ animations: "disabled" },
);
writeFileSync(path.join(suite.artifactDir, "after-inline.png"), frame.png);
}
await inline.getByRole("button", { name: "Open incident panel" }).click();
await expect.poll(() => page.locator(".incident-tools-card").count()).toBe(2);
await inline.getByRole("button", { name: "Acknowledge", exact: true }).click();
await expect
.poll(() => page.getByRole("button", { name: "Acknowledged", exact: true }).count())
.toBe(2);
expect((await gateway.getRequests("incident-tools.acknowledge")).length).toBe(1);
expect((await gateway.getRequests("chat.send")).length).toBe(0);
if (process.env.OPENCLAW_CAPTURE_UI_PROOF === "1") {
const frame = await takeControlUiScreenshotFrame(
page,
inline,
[inline.getByRole("button", { name: "Acknowledged", exact: true })],
{ animations: "disabled" },
);
writeFileSync(path.join(suite.artifactDir, "after-panel.png"), frame.png);
}
},
);
await suite.withPage(
{ viewport: { width: 1280, height: 900 }, serviceWorkers: "block" },
async ({ page }) => {
// A new page consumes persisted rows, not the live tool producer. Thirty independent calls
// make eager whole-history hydration observable through actual plugin read requests.
const history = Array.from({ length: 30 }, (_, index) => [
{
role: "assistant",
content: "Incident review " + (index + 1),
timestamp: now + 1000 + index * 10,
},
toolResult("history-" + index, now + 1001 + index * 10),
]).flat();
const gateway = await installMockGateway(page, {
agentModel: "openai/gpt-4.1",
sessionKey,
historyMessages: history,
nativePlugins: [
{
pluginId: "incident-tools",
rootDir: path.resolve("examples/inline-incident"),
source: "./control-ui.ts",
inlineViews: [descriptor],
},
],
featureMethods: [
...defaultControlUiFeatureMethods,
"plugins.uiDescriptors",
"plugins.controlUi.list",
"plugins.controlUi.report",
],
methodResponses: {
"incident-tools.get": incident,
"incident-tools.acknowledge": { ...incident, acknowledged: true },
},
});
await page.goto(controlUiSessionUrl(suite.server.baseUrl, sessionKey, "chat"));
await page.locator('[data-plugin-inline-view="history-29"] .incident-tools-card').waitFor();
await page.reload();
const last = page.locator('[data-plugin-inline-view="history-29"]');
await last.getByRole("heading", { name: incident.title }).waitFor();
const reads = (await gateway.getRequests("incident-tools.get")).length;
expect(reads).toBeGreaterThan(0);
expect(reads).toBeLessThan(15);
expect(await page.locator(".incident-tools-card").count()).toBeLessThan(10);
const thread = page.locator(".chat-pane-cache__pane--active .chat-thread");
await thread.evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll"));
});
const first = page.locator('[data-plugin-inline-view="history-0"]');
await first.getByRole("heading", { name: incident.title }).waitFor();
expect(await last.locator(".incident-tools-card").count()).toBe(0);
expect((await gateway.getRequests("chat.send")).length).toBe(0);
expect((await gateway.getRequests("incident-tools.acknowledge")).length).toBe(0);
// Keeping frontend code installed is insufficient after the current backend declaration is revoked.
const requests = await gateway.getRequests("plugins.controlUi.list");
expect(requests.length).toBeGreaterThan(0);
const { catalog } = await prepareNativeControlUiPluginFixtures([
{
pluginId: "incident-tools",
rootDir: path.resolve("examples/inline-incident"),
source: "./control-ui.ts",
inlineViews: [descriptor],
},
]);
await gateway.setMethodResponse("plugins.controlUi.list", {
...catalog,
inlineViews: [{ ...descriptor, pluginId: "incident-tools", version: 2 }],
});
await gateway.emitGatewayEvent("plugins.controlUi.changed", { revision: "incompatible" });
await first.getByText("This view is unavailable.", { exact: false }).waitFor();
await gateway.setMethodResponse("plugins.controlUi.list", {
...catalog,
revision: "revoked",
plugins: [],
inlineViews: [],
});
await gateway.emitGatewayEvent("plugins.controlUi.changed", { revision: "revoked" });
await expect.poll(() => page.locator(".incident-tools-card").count()).toBe(0);
await first.getByText("This view is unavailable.", { exact: false }).waitFor();
expect(await page.getByText(summary, { exact: true }).count()).toBeGreaterThan(0);
// The saved payload survived unsupported/missing states; reavailability only hydrates the plugin.
await gateway.setMethodResponse("plugins.controlUi.list", {
...catalog,
revision: "restored",
});
await gateway.emitGatewayEvent("plugins.controlUi.changed", { revision: "restored" });
await first.getByRole("heading", { name: incident.title }).waitFor();
expect((await gateway.getRequests("chat.send")).length).toBe(0);
},
);
});
});
@@ -0,0 +1,368 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterAll, expect, it } from "vitest";
import {
buildPluginControlUi,
writePluginBuildManifest,
} from "../../../src/cli/plugins-control-ui-build.js";
import {
appendTranscriptMessage,
replaceSessionEntrySync,
} from "../../../src/config/sessions/session-accessor.js";
import { GatewayClient } from "../../../src/gateway/client.js";
import type { ControlUiHost } from "../../../src/plugin-sdk/control-ui.js";
import { createDeferredCore } from "../../../src/shared/deferred.js";
import { ensureGatewayOwnerProfile } from "../../../src/state/user-profiles.js";
import {
createOpenClawTestInstance,
type OpenClawTestInstance,
} from "../../../test/helpers/openclaw-test-instance.ts";
import { createRequireRecord } from "../../../test/helpers/record.js";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import { evaluateControlUiContext } from "../test-helpers/control-ui-e2e-context.ts";
import { waitForControlUiGatewayReady } from "../test-helpers/control-ui-e2e-readiness.ts";
import { controlUiSessionUrl } from "../test-helpers/control-ui-e2e.ts";
import { enterControlUiSession } from "../test-helpers/control-ui-session-entry.ts";
import { verifyGatewayServedControlUiBundle } from "./control-ui-auth-proof.test-support.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const sessionKey = "agent:main:inline-authority";
const sessionId = "inline-authority-session";
const toolCallId = "saved-incident-call";
const requireRecord = createRequireRecord("record", "expected-object-value");
let instance: OpenClawTestInstance;
const builds = useAutoCleanupTempDirTracker(afterAll);
type MountProbe = {
host: ControlUiHost;
signal: AbortSignal;
hydration: "pending" | "resolved" | "rejected";
error?: string;
};
declare global {
interface Window {
inlineAuthorityProbe: MountProbe;
retiredInlineAuthorityProbe: MountProbe;
}
}
// Observe the example's genuine scoped host and hydration promise without substituting
// registration, auth, lifecycle, requests, responses, or the example's renderer.
const browserProbe = `import example from "./control-ui.ts";
export default {
...example,
activate(host) {
const ui = new Proxy(host.ui, {
get(target, key) {
if (key !== "registerInlineView") return Reflect.get(target, key);
return (view) => target.registerInlineView({
...view,
mount(element, context) {
const probe = { host: context.host, signal: context.signal, hydration: "pending" };
window.inlineAuthorityProbe = probe;
const observedHost = new Proxy(context.host, {
get(target, key) {
if (key !== "request") return Reflect.get(target, key);
return (method, params) => {
const response = target.request(method, params);
if (method === "incident-tools.get") response.then(
() => { probe.hydration = "resolved"; },
(error) => { probe.hydration = "rejected"; probe.error = String(error); },
);
return response;
};
},
});
return view.mount(element, { ...context, host: observedHost });
},
});
},
});
return example.activate({ ...host, ui });
},
};`;
const suite = createControlUiE2eSuite({
name: "Restored native inline view authority through a real Gateway",
startServerBeforeBrowser: true,
async startServer() {
const owner = await createOpenClawTestInstance({
name: "inline-authority",
env: { OPENCLAW_TEST_MINIMAL_GATEWAY: "0" },
config: {
gateway: { controlUi: { enabled: true, experimental: { customPlugins: true } } },
cron: { enabled: false },
},
});
instance = owner;
try {
const rootDir = owner.state.path("incident-tools");
const exampleRoot = path.resolve("examples/inline-incident");
const buildParent = path.join(exampleRoot, ".artifacts");
await fs.mkdir(buildParent, { recursive: true });
// Build beside the example's installed devDependencies, then deploy only its package/assets.
const buildRoot = builds.make("inline-authority-", buildParent);
for (const name of [
"index.ts",
"control-ui.ts",
"control-ui.css",
"package.json",
"openclaw.plugin.json",
]) {
await fs.copyFile(path.join(exampleRoot, name), path.join(buildRoot, name));
}
await fs.writeFile(path.join(buildRoot, "authority-probe.ts"), browserProbe);
const controlUi = await buildPluginControlUi({
rootDir: buildRoot,
source: "./authority-probe.ts",
});
const manifest = requireRecord(
JSON.parse(await fs.readFile(path.join(buildRoot, "openclaw.plugin.json"), "utf8")),
);
await writePluginBuildManifest(buildRoot, { ...manifest, controlUi });
await fs.cp(buildRoot, rootDir, { recursive: true });
const config = requireRecord(JSON.parse(await fs.readFile(owner.configPath, "utf8")));
await owner.state.writeConfig({
...config,
plugins: {
allow: ["incident-tools"],
load: { paths: [rootDir] },
entries: { "incident-tools": { enabled: true } },
},
});
ensureGatewayOwnerProfile("Synthetic incident viewer", { env: owner.env });
// Seed an already-saved native tool result through the real transcript owner while
// the disposable Gateway is offline. This proof does not run a model or tool.
replaceSessionEntrySync(
{ agentId: "main", sessionKey, env: owner.env },
{
sessionId,
updatedAt: Date.now(),
label: "Incident authority proof",
visibility: "shared",
},
);
await appendTranscriptMessage(
{ agentId: "main", sessionKey, sessionId, env: owner.env },
{
message: {
role: "toolResult",
toolName: "show_incident",
toolCallId,
timestamp: Date.now(),
content: [{ type: "text", text: "INC-42: Elevated checkout latency — Investigating." }],
details: {
pluginInlineView: {
pluginId: "incident-tools",
viewId: "incident-dashboard",
version: 1,
payload: { incidentId: "INC-42" },
summary: "INC-42: Elevated checkout latency — Investigating.",
},
pluginInlineViewInvocation: {
pluginId: "incident-tools",
toolName: "show_incident",
toolCallId,
},
},
__openclaw: { runId: "saved-incident-run" },
},
},
);
await owner.startGateway();
return { baseUrl: `http://127.0.0.1:${owner.port}/`, close: () => owner.cleanup() };
} catch (error) {
await owner.cleanup();
throw error;
}
},
});
suite.define(() => {
it("enforces current-viewer writes and retires a restored view during real hydration", async () => {
const trace: Record<string, unknown>[] = [
{ servedControlUi: await verifyGatewayServedControlUiBundle(suite.server.baseUrl) },
];
const connected = createDeferredCore<string[]>();
const reader = new GatewayClient({
url: instance.url,
token: instance.gatewayToken,
env: instance.env,
clientName: "gateway-client",
mode: "backend",
role: "operator",
scopes: ["operator.read"],
deviceIdentity: null,
sharedStateMode: "read-only",
onHelloOk: (hello) => connected.resolve(hello.auth?.scopes ?? []),
onConnectError: connected.reject,
});
reader.start();
try {
expect(await connected.promise).toEqual(["operator.read"]);
const readIncident = () =>
reader.request<{ acknowledged: boolean }>("incident-tools.get", { incidentId: "INC-42" });
const beforeDenied = await readIncident();
expect(beforeDenied).toMatchObject({ acknowledged: false });
const denial = await reader
.request("incident-tools.acknowledge", { incidentId: "INC-42" })
.then(
() => "unexpected success",
(error: unknown) => String(error),
);
expect(denial).toContain("missing scope: operator.write");
const afterDenied = await readIncident();
expect(afterDenied).toMatchObject({ acknowledged: false });
trace.push({
viewerScopes: ["operator.read"],
mutation: "rejected",
reason: denial,
acknowledgedBefore: beforeDenied.acknowledged,
acknowledgedAfter: afterDenied.acknowledged,
});
const handoff = await instance.cli(["dashboard", "--json"]);
expect(handoff.code).toBe(0);
const issued = requireRecord(JSON.parse(handoff.stdout));
expect(typeof issued.browserUrl).toBe("string");
const url = new URL(controlUiSessionUrl(suite.server.baseUrl, sessionKey, "chat"));
url.hash = new URL(String(issued.browserUrl)).hash;
await suite.withPage(
{ viewport: { width: 1280, height: 900 }, serviceWorkers: "block" },
async ({ page }) => {
const requests: string[] = [];
const readIds = new Set<string>();
let holdNextRead = false;
let releaseReply: (() => void) | undefined;
const held = createDeferredCore();
// Forward the real transport unchanged; pause one authentic hydration reply
// only after the registered backend handler has produced it.
await page.routeWebSocket(instance.url + "**", (socket) => {
const server = socket.connectToServer();
socket.onMessage((message) => {
const frame = requireRecord(JSON.parse(message.toString()));
if (frame.type === "req" && typeof frame.method === "string") {
requests.push(frame.method);
if (
frame.method === "incident-tools.get" &&
holdNextRead &&
typeof frame.id === "string"
) {
holdNextRead = false;
readIds.add(frame.id);
}
}
server.send(message);
});
server.onMessage((message) => {
const frame = requireRecord(JSON.parse(message.toString()));
if (typeof frame.id === "string" && readIds.delete(frame.id)) {
expect(frame.ok).toBe(true);
releaseReply = () => socket.send(message);
held.resolve();
} else {
socket.send(message);
}
});
});
await page.goto(url.toString());
await enterControlUiSession(page);
await waitForControlUiGatewayReady(page);
const inline = page.locator('[data-plugin-inline-view="' + toolCallId + '"]');
await inline.getByRole("heading", { name: "Elevated checkout latency" }).waitFor();
await page.reload();
await inline.getByRole("heading", { name: "Elevated checkout latency" }).waitFor();
const scopes = await evaluateControlUiContext(
page,
(context) => context.gateway.snapshot.hello?.auth?.scopes,
);
expect(scopes).toContain("operator.write");
await inline.getByRole("button", { name: "Acknowledge", exact: true }).click();
await inline.getByRole("button", { name: "Acknowledged", exact: true }).waitFor();
const acknowledged = await readIncident();
expect(acknowledged).toMatchObject({ acknowledged: true });
expect(requests.filter((method) => method === "incident-tools.acknowledge")).toHaveLength(
1,
);
trace.push({
restoredFromHistory: true,
viewerScopes: scopes,
mutation: "acknowledged",
independentBackendRead: { acknowledged: acknowledged.acknowledged },
});
holdNextRead = true;
await evaluateControlUiContext(page, (context) => context.plugins.refresh());
await held.promise;
await page.evaluate(() => {
window.retiredInlineAuthorityProbe = window.inlineAuthorityProbe;
if (window.retiredInlineAuthorityProbe.hydration !== "pending") {
throw new Error("Hydration was not pending");
}
});
// Catalog refresh retires the mounted-view scope while its hydration is pending.
await evaluateControlUiContext(page, (context) => context.plugins.refresh());
expect(await page.evaluate(() => window.retiredInlineAuthorityProbe.signal.aborted)).toBe(
true,
);
await expect
.poll(() => page.evaluate(() => window.inlineAuthorityProbe.hydration))
.toBe("resolved");
releaseReply?.();
await expect
.poll(() => page.evaluate(() => window.retiredInlineAuthorityProbe.hydration))
.toBe("rejected");
const before = requests.filter((method) => method.startsWith("incident-tools."));
const retired = await page.evaluate(async () => {
const probe = window.retiredInlineAuthorityProbe;
const outcomes = [];
for (const method of ["incident-tools.get", "incident-tools.acknowledge"]) {
outcomes.push(
await probe.host.request(method, { incidentId: "INC-43" }).then(
() => "unexpected success",
(error: unknown) => String(error),
),
);
}
return { hydrationError: probe.error, outcomes };
});
expect(retired.hydrationError).toMatch(/ended|active|current|disposed/i);
expect(retired.outcomes).toHaveLength(2);
for (const outcome of retired.outcomes) {
expect(outcome).toMatch(/ended|active|current|disposed/i);
}
// A live read is a same-socket barrier after the retained-host attempts. It also
// proves the observer still sees authenticated traffic and INC-43 was not mutated.
const afterRetirement = await page.evaluate(() =>
window.inlineAuthorityProbe.host.request<{ acknowledged: boolean }>(
"incident-tools.get",
{ incidentId: "INC-43" },
),
);
expect(afterRetirement).toMatchObject({ acknowledged: false });
const after = requests.filter((method) => method.startsWith("incident-tools."));
expect(after.slice(before.length)).toEqual(["incident-tools.get"]);
const modelTurns = requests.filter((method) => method === "chat.send").length;
expect(modelTurns).toBe(0);
trace.push({
retiredDuringHydration: true,
...retired,
newAuthenticatedRequestsAfterRetirement: after.length - before.length - 1,
liveReadBarrierRequests: 1,
unmodifiedIncident: {
incidentId: "INC-43",
acknowledged: afterRetirement.acknowledged,
},
modelTurns,
});
},
);
} finally {
await reader.stopAndWait();
await fs.writeFile(
path.join(suite.artifactDir, "authority-trace.json"),
JSON.stringify(trace, null, 2),
);
}
});
});
@@ -197,7 +197,7 @@ suite.define(() => {
.getByRole("button", { name: "Close", exact: true })
.click();
await mobileHost.waitFor({ state: "hidden" });
await openChatPanels(page);
await openChatPanels(page, "mobile");
await mobileHost.waitFor();
expect(await composer.inputValue()).toBe(draft);
await page.setViewportSize({ width: 1440, height: 900 });
+51 -1
View File
@@ -8,7 +8,11 @@ import {
installMockGateway,
type MockGatewayControls,
} from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
import { controlUiE2eBuiltModuleRequest } from "./control-ui-built-module.test-support.ts";
import {
createControlUiE2eSuite,
holdModuleResponse,
} from "./control-ui-e2e-suite.test-support.ts";
import { withWidgetChromeDiagnostics } from "./solo-widget-chrome.test-support.ts";
const suite = createControlUiE2eSuite({ name: "single widget chrome" });
@@ -122,6 +126,52 @@ async function updateBoard(gateway: MockGatewayControls, board: BoardSnapshot) {
}
suite.define(() => {
it("relocates singleton chrome after a cold board import without another owner publication", async () => {
await suite.withPage(
{ viewport: { width: 1280, height: 900 }, colorScheme: "dark", serviceWorkers: "block" },
async ({ page }) => {
const held = await holdModuleResponse(
page,
controlUiE2eBuiltModuleRequest("ui/src/components/board/board-view.tsx"),
);
try {
const opening = openDashboard(page, snapshot());
await held.request;
const { gateway } = await opening;
expect(
await page.evaluate(() => Boolean(customElements.get("openclaw-board-view"))),
).toBe(false);
expect(await page.locator("openclaw-board-view").count()).toBe(0);
expect(await gateway.getRequests("board.update")).toHaveLength(0);
// The header may offer the data-owned action before code loads; no live
// view exists to admit it yet.
const pendingMenu = await showDashboardMenu(page);
await pendingMenu.locator('[data-value="resize:xl"]').click();
expect(await gateway.getRequests("board.update")).toHaveLength(0);
held.release();
const widget = page.locator('[data-widget-name="release-status"]');
const frame = widget.locator("iframe");
await frame
.contentFrame()
.getByRole("textbox", { name: "Release note" })
.fill("Keep this local draft");
await widget.focus();
expect(await widget.locator(chrome).count()).toBe(0);
expect(
await page.evaluate(
() => document.querySelector("openclaw-board-view")?.pageWidgetName,
),
).toBe("release-status");
const menu = await showDashboardMenu(page);
await menu.locator('[data-value="remove"]').waitFor();
expect(await gateway.getRequests("board.update")).toHaveLength(0);
} finally {
held.release();
}
},
);
});
it("retains page controls in split view and restores multi-widget controls without reloading", async () => {
await suite.withPage(
{ viewport: { width: 1280, height: 900 }, serviceWorkers: "block" },
@@ -232,6 +232,7 @@ const enPluginManagement = {
description: "Adds information or controls alongside sessions.",
},
widget: { name: "Dashboard widgets", description: "Adds widgets to the dashboard." },
inline: { name: "Inline views", description: "Shows native plugin views in conversations." },
replacement: {
name: "Interface replacements",
description: "Replaces supported parts of the interface.",
+3
View File
@@ -343,6 +343,9 @@ export const en: TranslationMap & {
selectionScope: "Choose views for this browser window. Built-in views are always available.",
builtin: "Built-in view",
retryView: "Retry plugin view",
inlineLoading: "Loading plugin view…",
inlineUnavailable:
"This view is unavailable. Check that its plugin is installed, enabled, and allowed for your account. Reload the conversation after installing or updating the plugin.",
reload: "Reload plugin UI",
dashboardTitle: "Session dashboard",
dashboardEmpty: "This session has no dashboard widgets yet.",
+1
View File
@@ -392,6 +392,7 @@ export type MessageImageSource = {
};
export type MessageContentItem =
| import("../../../../src/shared/plugin-inline-view.js").PluginInlineViewBlock
| ClawHubRecommendation
| {
type: "image";
+5
View File
@@ -20,6 +20,7 @@ import {
import { projectChatWorkContextForDisplay } from "../../../../src/chat/work-context.js";
import { splitMediaFromOutput } from "../../../../src/media/parse.js";
import { readClawHubRecommendation } from "../../../../src/shared/clawhub-recommendations.js";
import { readPluginInlineViewBlock } from "../../../../src/shared/plugin-inline-view.js";
import { getMediaFileExtension } from "../media-file-extension.ts";
import type { NormalizedMessage, MessageContentItem, MessageImageSource } from "./chat-types.ts";
import { projectImportedMessageForDisplay } from "./imported-message-display.ts";
@@ -523,6 +524,10 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
return [image];
}
const type = item.type;
if (type === "plugin_view") {
const inline = isAssistantMessage ? readPluginInlineViewBlock(item) : undefined;
return inline ? [inline] : [];
}
if (type === "clawhub") {
const recommendation = isAssistantMessage ? readClawHubRecommendation(item) : null;
return recommendation ? [recommendation] : [];
+83
View File
@@ -0,0 +1,83 @@
import { readSessionMessageIdentity } from "@openclaw/gateway-client/browser";
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { readPluginInlineViewBlock } from "../../../../src/shared/plugin-inline-view.js";
import type { ChatItem } from "./chat-types.ts";
type MessageItem = Extract<ChatItem, { kind: "message" }>;
/** Each admitted result owns a row; never queue across turns or scan text/guest JSON. */
export function pluginInlineViewItems(item: MessageItem, seen: Set<string>): MessageItem[] {
const entry = asOptionalRecord(item.message);
if (!entry || entry.role === "user" || entry.isError === true) {
return [];
}
const items: MessageItem[] = [];
const add = (value: unknown, result = entry) => {
const block = readPluginInlineViewBlock(value);
if (!block) {
return;
}
const messageIdentity = readSessionMessageIdentity(entry, {
// A raw id equal to the call id may be a tool alias, not a durable message receipt.
messageId:
normalizeOptionalString(entry.messageId) ??
(entry.id === block.toolCallId ? undefined : entry.id),
});
// Nested tool blocks carry their own producer facts even when their enclosing row differs.
const resultIdentity =
result === entry
? messageIdentity
: readSessionMessageIdentity({
role: "toolResult",
__openclaw: result["__openclaw"],
idempotencyKey: result.idempotencyKey,
});
const runId =
resultIdentity?.runId ??
normalizeOptionalString(result.runId) ??
messageIdentity?.runId ??
normalizeOptionalString(entry.runId);
const externalSource = resultIdentity?.externalSource ?? messageIdentity?.externalSource;
const messageId = resultIdentity?.id ?? messageIdentity?.id;
const sequence = resultIdentity?.sequence ?? messageIdentity?.sequence;
// Legacy rows without ownership use their receipt or the transcript owner's existing source
// key. A bare call id cannot prove that an unscoped row belongs to another run's live result.
const scope = runId
? ["run", runId]
: externalSource
? ["external", externalSource]
: messageId
? ["message", messageId]
: sequence != null
? ["sequence", sequence]
: ["source", item.key];
const key = "plugin-view:" + JSON.stringify([...scope, block.toolCallId]);
if (!seen.has(key)) {
seen.add(key);
items.push({
kind: "message",
key,
message: {
role: "assistant",
id: key,
timestamp: entry.timestamp,
runId,
content: [{ type: "text", text: block.summary }, block],
},
});
}
};
add(asOptionalRecord(entry.details)?.pluginView);
for (const value of Array.isArray(entry.content) ? entry.content : []) {
const block = asOptionalRecord(value);
if (
block &&
["toolResult", "toolresult", "tool_result"].includes(String(block.type)) &&
block.isError !== true
) {
add(asOptionalRecord(block.details)?.pluginView, block);
}
}
return items;
}
+1 -1
View File
@@ -584,13 +584,13 @@ export abstract class ChatPaneBoard extends ChatPaneHistory {
}
private pageBoardWidget(layout: SidebarLayout | undefined, board: ResolvedBoardView) {
// Eligibility belongs to the loaded snapshot, not the lazy renderer registration.
if (
!layout ||
!this.state ||
!selectedChatSessionRow(this.state) ||
!this.visuallyPresented ||
!board.provider.hasLoadedSnapshot ||
!customElements.get("openclaw-board-view") ||
!isSidebarSlotVisible(layout, "dashboard")
) {
return undefined;
+44 -110
View File
@@ -1,7 +1,6 @@
import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { readNodePermissionRequest } from "../../../../packages/gateway-protocol/src/node-permissions.js";
import { extractCanvasShortcodes } from "../../../../src/chat/canvas-render.js";
import { composeTranscriptDisplay } from "../../../../src/chat/transcript-display-position.js";
import { readModelFallbackNotice } from "../../../../src/shared/model-fallback-notice.js";
import type { QuestionPrompt } from "../../app/question-prompt.ts";
@@ -24,10 +23,10 @@ import { readChatThreadMessageIdentity } from "../../lib/chat/history-message-id
import { extractTextCached } from "../../lib/chat/message-extract.ts";
import {
canvasPreviewsMatch,
readCanvasContentPreview,
normalizeRoleForGrouping,
} from "../../lib/chat/message-normalizer.ts";
import { extractToolCardsCached, type CanvasToolPreview } from "../../lib/chat/tool-cards.ts";
import { pluginInlineViewItems } from "../../lib/chat/plugin-inline-views.ts";
import { extractToolCardsCached } from "../../lib/chat/tool-cards.ts";
import {
buildCompactionDividerItem,
buildGuardianNoticeItem,
@@ -45,6 +44,11 @@ import {
projectSessionsYieldItems,
} from "./chat-sessions-yield.ts";
import { projectChatSystemNotice } from "./chat-system-notice.ts";
import {
canvasAssistantItemKey,
collectCanvasTurns,
reconcileLiveCanvasPreviews,
} from "./chat-thread-canvas.ts";
import { groupMessages } from "./chat-thread-grouping.ts";
import {
placeChatInputs,
@@ -83,7 +87,6 @@ import {
} from "./chat-thread-run-identity.ts";
import { coalesceToolActivityMessages } from "./chat-tool-activity-coalesce.ts";
import { safeNormalizeMessage } from "./chat-turn-boundary.ts";
import { copyLiveTerminalIdentity, readLiveTerminalRunId } from "./terminal-message-identity.ts";
import type { ChatReasoning, CompactionStatus } from "./tool-stream-contract.ts";
export type BuildChatItemsProps = ChatInputPlacementProps & {
@@ -121,20 +124,12 @@ export type BuildChatItemsProps = ChatInputPlacementProps & {
replyLocalPerson?: string;
};
function canvasAssistantItemKey(
message: unknown,
source: Parameters<typeof canvasPreviewBaseIdentity>[1],
fallback: string,
): string {
const identity = canvasPreviewBaseIdentity(message, source);
return identity ? `canvas:${identity}` : `${fallback}:canvas`;
}
export function buildChatItems(
props: BuildChatItemsProps,
inputOrder: ChatInputOrderState = { keys: [] },
): Array<ChatItem | MessageGroup> {
let items: ChatItem[] = [];
const inlineInvocations = new Set<string>();
const tools = props.toolMessages.filter(
(message): message is Record<string, unknown> => asRecord(message) !== null,
);
@@ -188,6 +183,14 @@ export function buildChatItems(
);
const searchFiltering = props.searchOpen === true && Boolean(props.searchQuery?.trim());
const hiddenHistoryKeys = new Set<string>();
const hideSearchMiss = (item: Extract<ChatItem, { kind: "message" }>) => {
if (
searchFiltering &&
!messageMatchesSearchQuery(item.message, props.searchQuery ?? "", props.messageRecovery)
) {
hiddenHistoryKeys.add(item.key);
}
};
const canvasIdentities = new Set<string>();
const normalizedHistory = history.map(safeNormalizeMessage);
const permissionInMessage = (message: unknown) =>
@@ -195,36 +198,13 @@ export function buildChatItems(
readNodePermissionRequest(asRecord(card.details)?.permissionMissing),
);
const historyItems = buildMessageItems(history);
let canvasTurn: {
previews: { preview: CanvasToolPreview; item: (typeof historyItems)[number] }[];
lastMatchingAssistantIndex: number;
} = {
previews: [],
lastMatchingAssistantIndex: -1,
};
// Rows in one turn share these facts so a tool result can see the assistant
// projection that follows it, without consuming a view from another turn.
const canvasTurns = historyItems.map((item, index) => {
const message = normalizedHistory[index];
const role = message && normalizeRoleForGrouping(message.role);
if (role === "user" || role === "system") {
canvasTurn = { previews: [], lastMatchingAssistantIndex: -1 };
}
if (
role === "assistant" &&
message &&
(!searchFiltering ||
messageMatchesSearchQuery(history[index], props.searchQuery ?? "", props.messageRecovery))
) {
canvasTurn.lastMatchingAssistantIndex = index;
canvasTurn.previews.push(
...message.content.flatMap((block) =>
block.type === "canvas" ? [{ preview: block.preview, item }] : [],
),
);
}
return canvasTurn;
});
const canvasTurns = collectCanvasTurns(
historyItems,
normalizedHistory,
(message) =>
!searchFiltering ||
messageMatchesSearchQuery(message, props.searchQuery ?? "", props.messageRecovery),
);
const compaction = props.compactionStatus;
const compactionKey = compaction
? `divider:compaction:live:${compaction.runId}:${compaction.itemId ?? "manual"}`
@@ -291,6 +271,10 @@ export function buildChatItems(
continue;
}
for (const inline of pluginInlineViewItems(item, inlineInvocations)) {
hideSearchMiss(inline);
items.push(inline);
}
const isToolResult = normalized.role.toLowerCase() === "toolresult";
const persistedCanvasSource = isToolResult ? extractChatMessagePreview(msg) : null;
let duplicatePersistedCanvas = false;
@@ -341,14 +325,7 @@ export function buildChatItems(
continue;
}
const searchQuery = props.searchQuery ?? "";
if (
props.searchOpen &&
searchQuery.trim() &&
!messageMatchesSearchQuery(msg, searchQuery, props.messageRecovery)
) {
hiddenHistoryKeys.add(itemKey);
}
hideSearchMiss(item);
if (
!hasRenderableNormalizedMessage(msg, normalized) &&
normalized.role.toLowerCase() !== "assistant"
@@ -454,65 +431,7 @@ export function buildChatItems(
message: createCanvasAssistantMessage(preview, timestamp),
});
}
// Tool receipts can own a visible preview before an assistant row arrives.
// Reconcile terminal copies only after all of those projections exist.
const ownedCanvas = items.flatMap((item) => {
if (
item.kind !== "message" ||
readLiveTerminalRunId(item.message) ||
hiddenHistoryKeys.has(item.key) ||
hiddenKeys.has(item.key)
) {
return [];
}
const runId = transcriptRunId(item.message);
const message = runId ? safeNormalizeMessage(item.message) : null;
if (!message || normalizeRoleForGrouping(message.role) !== "assistant") {
return [];
}
return message.content.flatMap((block) =>
block.type === "canvas" ? [{ runId, preview: block.preview }] : [],
);
});
for (const item of items) {
if (item.kind !== "message") {
continue;
}
const runId = readLiveTerminalRunId(item.message);
const terminal = runId ? asRecord(item.message) : null;
if (!terminal || !Array.isArray(terminal.content)) {
continue;
}
const isOwned = (preview: Pick<CanvasToolPreview, "viewId" | "url">) =>
ownedCanvas.some(
(entry) => entry.runId === runId && canvasPreviewsMatch(entry.preview, preview),
);
let changed = false;
const content = terminal.content.flatMap((value) => {
const preview = readCanvasContentPreview(value);
if (preview && isOwned(preview)) {
changed = true;
return [];
}
const block = asRecord(value);
if (block?.type === "text" && typeof block.text === "string") {
const extracted = extractCanvasShortcodes(block.text, isOwned);
if (extracted.previews.length > 0) {
changed = true;
return [{ ...block, text: extracted.text }];
}
}
return [value];
});
if (changed) {
const projection = { ...terminal, content };
copyLiveTerminalIdentity(terminal, projection);
item.message = projection;
}
}
items = items.filter(
(item) => item.kind !== "message" || hasRenderableNormalizedMessage(item.message),
);
items = reconcileLiveCanvasPreviews(items, hiddenHistoryKeys, hiddenKeys);
const projections: ChatProjection[] = [];
if (compaction && compactionKey && !hasPersistedCompaction) {
const timestamp = compaction.startedAt ?? compaction.completedAt ?? Date.now();
@@ -701,6 +620,21 @@ export function buildChatItems(
items,
toolItems.map((tool) => tool.projection),
);
for (const tool of toolItems) {
for (const item of pluginInlineViewItems(tool.projection.item, inlineInvocations)) {
hideSearchMiss(item);
projections.push({
...tool.projection,
bounds:
tool.projection.bounds ??
resolveProjectionBounds(
tool.runId,
normalizeOptionalString(tool.projection.item.message.openclawToolStreamAfterSendId),
),
item,
});
}
}
insertChatItemsByTimestamp(items, projections);
// The active claw is telemetry, not a placeholder: it stays through visible
+119
View File
@@ -0,0 +1,119 @@
import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import { extractCanvasShortcodes } from "../../../../src/chat/canvas-render.js";
import type { ChatItem, NormalizedMessage } from "../../lib/chat/chat-types.ts";
import {
canvasPreviewsMatch,
readCanvasContentPreview,
normalizeRoleForGrouping,
} from "../../lib/chat/message-normalizer.ts";
import type { CanvasToolPreview } from "../../lib/chat/tool-cards.ts";
import { canvasPreviewBaseIdentity, hasRenderableNormalizedMessage } from "./chat-thread-items.ts";
import { transcriptRunId } from "./chat-thread-run-identity.ts";
import { safeNormalizeMessage } from "./chat-turn-boundary.ts";
import { copyLiveTerminalIdentity, readLiveTerminalRunId } from "./terminal-message-identity.ts";
export function canvasAssistantItemKey(
message: unknown,
source: Parameters<typeof canvasPreviewBaseIdentity>[1],
fallback: string,
): string {
const identity = canvasPreviewBaseIdentity(message, source);
return identity ? `canvas:${identity}` : `${fallback}:canvas`;
}
export function collectCanvasTurns(
historyItems: Array<Extract<ChatItem, { kind: "message" }>>,
normalizedHistory: Array<NormalizedMessage | null>,
matchesSearch: (message: unknown) => boolean,
) {
let canvasTurn: {
previews: { preview: CanvasToolPreview; item: (typeof historyItems)[number] }[];
lastMatchingAssistantIndex: number;
} = {
previews: [],
lastMatchingAssistantIndex: -1,
};
// Rows in one turn share these facts so a tool result can see the assistant
// projection that follows it, without consuming a view from another turn.
return historyItems.map((item, index) => {
const message = normalizedHistory[index];
const role = message && normalizeRoleForGrouping(message.role);
if (role === "user" || role === "system") {
canvasTurn = { previews: [], lastMatchingAssistantIndex: -1 };
}
if (role === "assistant" && message && matchesSearch(item.message)) {
canvasTurn.lastMatchingAssistantIndex = index;
canvasTurn.previews.push(
...message.content.flatMap((block) =>
block.type === "canvas" ? [{ preview: block.preview, item }] : [],
),
);
}
return canvasTurn;
});
}
/** Reconcile terminal copies after owned previews exist, retaining full rows for causal placement. */
export function reconcileLiveCanvasPreviews(
items: ChatItem[],
hiddenHistoryKeys: ReadonlySet<string>,
hiddenInputKeys: ReadonlySet<string>,
): ChatItem[] {
const ownedCanvas = items.flatMap((item) => {
if (
item.kind !== "message" ||
readLiveTerminalRunId(item.message) ||
hiddenHistoryKeys.has(item.key) ||
hiddenInputKeys.has(item.key)
) {
return [];
}
const runId = transcriptRunId(item.message);
const message = runId ? safeNormalizeMessage(item.message) : null;
if (!message || normalizeRoleForGrouping(message.role) !== "assistant") {
return [];
}
return message.content.flatMap((block) =>
block.type === "canvas" ? [{ runId, preview: block.preview }] : [],
);
});
for (const item of items) {
if (item.kind !== "message") {
continue;
}
const runId = readLiveTerminalRunId(item.message);
const terminal = runId ? asRecord(item.message) : null;
if (!terminal || !Array.isArray(terminal.content)) {
continue;
}
const isOwned = (preview: Pick<CanvasToolPreview, "viewId" | "url">) =>
ownedCanvas.some(
(entry) => entry.runId === runId && canvasPreviewsMatch(entry.preview, preview),
);
let changed = false;
const content = terminal.content.flatMap((value) => {
const preview = readCanvasContentPreview(value);
if (preview && isOwned(preview)) {
changed = true;
return [];
}
const block = asRecord(value);
if (block?.type === "text" && typeof block.text === "string") {
const extracted = extractCanvasShortcodes(block.text, isOwned);
if (extracted.previews.length > 0) {
changed = true;
return [{ ...block, text: extracted.text }];
}
}
return [value];
});
if (changed) {
const projection = { ...terminal, content };
copyLiveTerminalIdentity(terminal, projection);
item.message = projection;
}
}
return items.filter(
(item) => item.kind !== "message" || hasRenderableNormalizedMessage(item.message),
);
}
@@ -0,0 +1,288 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import type { PluginInlineViewBlock } from "../../../../src/shared/plugin-inline-view.js";
import { extractTextCached } from "../../lib/chat/message-extract.ts";
import { normalizeMessage } from "../../lib/chat/message-normalizer.ts";
import { buildChatItems } from "./chat-thread-build.ts";
import { createProps } from "./chat-thread.test-support.ts";
const block = (id: string): PluginInlineViewBlock => ({
type: "plugin_view",
pluginId: "incidents",
viewId: "incident",
toolCallId: id,
toolName: "show_incident",
summary: "Showing " + id,
version: 1,
payload: { incidentId: id },
});
const result = (id: string, timestamp: number) => ({
role: "toolResult",
toolCallId: id,
toolName: "show_incident",
timestamp,
content: [{ type: "text", text: "Showing " + id }],
details: { pluginView: block(id) },
});
function inlineRows(props: Parameters<typeof buildChatItems>[0]) {
return buildChatItems(props)
.flatMap<{ key: string; message: unknown }>((item) =>
item.kind === "group" ? item.messages : item.kind === "message" ? [item] : [],
)
.filter(({ message }) =>
normalizeMessage(message).content.some((item) => item.type === "plugin_view"),
);
}
function inlineMessages(props: Parameters<typeof buildChatItems>[0]) {
return inlineRows(props).map(({ message }) => normalizeMessage(message));
}
describe("inline plugin transcript rows", () => {
it.each([false, true])(
"searches derived history and live summaries with tool details visible=%s",
(showToolCalls) => {
const saved = ["one", "two"].map((id, index) =>
Object.assign(result(id, index + 2), {
__openclaw: { id: "saved-" + id, runId: "run" },
content: [{ type: "text", text: "Tool metadata received." }],
}),
);
const live = ["one", "two"].map((id, index) => ({
role: "assistant",
runId: "run",
toolCallId: id,
timestamp: index + 2,
content: [
{ type: "toolresult", name: "show_incident", details: { pluginView: block(id) } },
],
}));
for (const source of ["history", "live", "settled"]) {
const props = createProps({
showToolCalls,
messages: source === "live" ? [] : saved,
toolMessages: source === "history" ? [] : live,
});
const unfiltered = inlineRows(props);
expect(unfiltered).toHaveLength(2);
for (const [searchOpen, searchQuery, indexes] of [
[true, " SHOWING ONE ", [0]],
[true, "showing", [0, 1]],
[true, "unrelated", []],
[true, "Tool metadata received", []],
[true, " ", [0, 1]],
[false, "unrelated", [0, 1]],
] as const) {
const rows = inlineRows({ ...props, searchOpen, searchQuery });
expect(
rows.map(({ message }) => extractTextCached(message)),
source + ": " + searchQuery,
).toEqual(indexes.map((index) => ["Showing one", "Showing two"][index]));
expect(rows.map(({ key }) => key)).toEqual(
indexes.map((index) => unfiltered[index]!.key),
);
}
}
},
);
it("shows two independent calls immediately with tool details hidden and deduplicates history settlement", () => {
const live = (id: string, timestamp: number) => ({
...result(id, timestamp),
role: "assistant",
runId: "run",
content: [
{ type: "toolcall", name: "show_incident", arguments: {} },
{ type: "toolresult", name: "show_incident", details: { pluginView: block(id) } },
],
});
const props = createProps({
showToolCalls: false,
runId: "run",
messages: [{ role: "user", content: "Show incidents", timestamp: 1 }],
toolMessages: [live("one", 3), live("two", 4)],
streamSegments: [{ text: "Here are the incidents.", ts: 2, toolCallId: "one", runId: "run" }],
stream: "",
streamStartedAt: 2,
});
expect(inlineMessages(props).map((message) => message.content)).toEqual([
[{ type: "text", text: "Showing one", name: undefined, args: undefined }, block("one")],
[{ type: "text", text: "Showing two", name: undefined, args: undefined }, block("two")],
]);
expect(
inlineMessages({
...props,
messages: [
...props.messages,
{ ...result("one", 3), __openclaw: { id: "saved-one", runId: "run" } },
],
}),
).toHaveLength(2);
});
it("keeps tool-only/aborted history and page boundaries local instead of moving views into another turn", () => {
for (const messages of [
[result("one", 1)],
[result("one", 1), { role: "user", content: "A different request", timestamp: 2 }],
]) {
const rows = inlineMessages(createProps({ messages, showToolCalls: false }));
expect(rows).toHaveLength(1);
expect(rows[0]?.timestamp).toBe(1);
}
expect(
inlineMessages(
createProps({
messages: [{ role: "assistant", content: "Unrelated next page", timestamp: 3 }],
showToolCalls: false,
}),
),
).toEqual([]);
});
it("keeps reused direct-call IDs distinct across runs and stable through history settlement", () => {
const callId = "tool_call:direct:show_incident:1";
const live = (runId: string) => ({
...result(callId, 10),
role: "assistant",
runId,
content: [
{ type: "toolresult", name: "show_incident", details: { pluginView: block(callId) } },
],
});
const props = createProps({
showToolCalls: false,
toolMessages: [live("run-a"), live("run-a"), live("run-b")],
});
const liveRows = inlineRows(props);
expect(liveRows).toHaveLength(2);
expect(liveRows.map(({ message }) => message)).toMatchObject([
{ runId: "run-a" },
{ runId: "run-b" },
]);
expect(new Set(liveRows.map(({ key }) => key)).size).toBe(2);
const persisted = ["run-a", "run-b"].map((runId) =>
Object.assign(result(callId, 10), {
runId: "stale-envelope",
__openclaw: { id: "saved-" + runId, runId },
}),
);
const settled = inlineRows({ ...props, messages: [...persisted, persisted[0]] });
expect(settled).toHaveLength(2);
expect(settled.map(({ key }) => key)).toEqual(liveRows.map(({ key }) => key));
expect(settled.map(({ message }) => normalizeMessage(message).id)).toEqual(
liveRows.map(({ message }) => normalizeMessage(message).id),
);
expect(settled.map(({ message }) => message)).toMatchObject([
{ runId: "run-a" },
{ runId: "run-b" },
]);
});
it("preserves nested result owners rather than the enclosing message owner", () => {
const callId = "tool_call:direct:show_incident:1";
const content = [
{
type: "toolResult",
toolCallId: callId,
toolName: "show_incident",
runId: "nested-a",
details: { pluginView: block(callId) },
},
{
type: "toolResult",
toolCallId: callId,
toolName: "show_incident",
__openclaw: { runId: "nested-b" },
details: { pluginView: block(callId) },
},
];
const rows = inlineRows(
createProps({
showToolCalls: false,
messages: [
{
role: "custom",
timestamp: 10,
__openclaw: { id: "nested-row", runId: "enclosing-run" },
content: [...content, content[0]],
},
],
}),
);
expect(rows).toHaveLength(2);
expect(rows.map(({ message }) => message)).toMatchObject([
{ runId: "nested-a" },
{ runId: "nested-b" },
]);
expect(new Set(rows.map(({ key }) => key)).size).toBe(2);
expect(new Set(rows.map(({ message }) => normalizeMessage(message).id)).size).toBe(2);
});
it.each(["id", "surface-id", "surface-message-id", "sequence", "anonymous", "call-id-alias"])(
"uses stable source identity when run ownership is absent: %s",
(kind) => {
const callId = "reused-call";
const sources = [1, 2].map((index) =>
Object.assign(
result(callId, 10),
kind === "id"
? { __openclaw: { id: "row-" + index } }
: kind === "surface-id"
? { id: "row-" + index }
: kind === "surface-message-id"
? { messageId: "row-" + index }
: kind === "sequence"
? { __openclaw: { seq: index } }
: kind === "call-id-alias"
? { id: callId }
: {},
),
);
const props = createProps({ showToolCalls: false, messages: sources });
const rows = inlineRows(props);
expect(rows).toHaveLength(2);
expect(new Set(rows.map(({ key }) => key)).size).toBe(2);
expect(new Set(rows.map(({ message }) => normalizeMessage(message).id)).size).toBe(2);
expect(
inlineRows({
...props,
messages: [
{ role: "user", content: "Unrelated older message", timestamp: 1 },
...sources,
],
}).map(({ key }) => key),
).toEqual(rows.map(({ key }) => key));
if (kind === "surface-id" || kind === "surface-message-id") {
const settled = [1, 2].map((index) =>
Object.assign(result(callId, 10), { __openclaw: { id: "row-" + index } }),
);
expect(inlineRows({ ...props, messages: settled }).map(({ key }) => key)).toEqual(
rows.map(({ key }) => key),
);
}
if (kind !== "anonymous" && kind !== "call-id-alias") {
expect(inlineRows({ ...props, messages: [...sources, sources[0]] })).toHaveLength(2);
}
},
);
it("preserves explicitly projected fields and rejects user-authored inline blocks", () => {
expect(normalizeMessage({ role: "assistant", content: [block("one")] }).content).toEqual([
block("one"),
]);
expect(normalizeMessage({ role: "user", content: [block("one")] }).content).toEqual([]);
expect(
inlineMessages(
createProps({
messages: [
{
role: "user",
content: JSON.stringify(result("one", 1)),
details: { pluginView: block("one") },
},
],
}),
),
).toEqual([]);
});
});
@@ -179,9 +179,13 @@ export function prepareGroupedMessage(
const actionText = opts.messageActions?.markdown ?? displayMarkdown;
const omittedMedia = normalizedMessage.content.filter((item) => item.type === "omitted_media");
const assistantViewBlocks = normalizedMessage.content.filter((item) => item.type === "canvas");
const inlineViewBlocks = normalizedMessage.content.filter((item) => item.type === "plugin_view");
const hasImagesOrNotices = hasImages || expiredPairingQrCount !== 0 || omittedMedia.length !== 0;
const hasMediaContent =
hasImagesOrNotices || visibleAttachments.length !== 0 || assistantViewBlocks.length !== 0;
hasImagesOrNotices ||
visibleAttachments.length !== 0 ||
assistantViewBlocks.length !== 0 ||
inlineViewBlocks.length !== 0;
const clawHubCards = normalizedMessage.content.filter((item) => item.type === "clawhub");
const extractedThinking =
opts.showReasoning && role === "assistant" ? extractThinkingCached(message) : null;
@@ -318,6 +322,7 @@ export function prepareGroupedMessage(
actionText,
omittedMedia,
assistantViewBlocks,
inlineViewBlocks,
clawHubCards,
reasoningMarkdown,
markdown,
@@ -28,6 +28,7 @@ import { resolveToolDisplayIdentity } from "../../../lib/chat/tool-display-ident
import { elementRef } from "../../../lib/reactive/element-ref.ts";
import { registerEnglishCatalog, t } from "../../../lib/reactive/i18n.ts";
import { ContentPresentation } from "../../../lib/solid/presentation.ts";
import { PluginInlineView } from "../../../plugins/control-ui-inline-view.tsx";
import { PluginSurface } from "../../../plugins/control-ui-view.solid.tsx";
import "../../../components/solid/person-reference.tsx";
import { assistantMessageIsInterrupted } from "../chat-assistant-reply.ts";
@@ -525,6 +526,16 @@ function BubbleContents(props: ContentProps) {
</div>
</Show>
</Show>
<For each={props.state().inlineViewBlocks} keyed={(block) => block.toolCallId}>
{(block) => (
<PluginInlineView
block={block()}
sessionKey={props.options.sessionKey ?? ""}
agentId={props.options.agentId}
presented={props.options.presented}
/>
)}
</For>
<Show when={props.state().hasToolCards}>
<Show
when={
@@ -282,6 +282,7 @@ it("mounts only selected tool replacements and keeps their context, projection,
registryStatus: "complete",
canReload: false,
isLoading: () => false,
inlineViewLabel: () => undefined,
reload: async () => undefined,
refresh: async () => undefined,
registrations: () => [],
@@ -244,6 +244,9 @@ describe("session workspace path actions", () => {
clipboard();
const entry = file("README.md");
const result = { sessionKey: "agent:main:current", root: "/workspace", files: [entry] };
const fileRequested = createDeferred();
const fileRead = createDeferred();
onTestFinished(() => fileRead.resolve());
const state = {
client: { request: vi.fn().mockResolvedValue({ artifacts: [] }) },
connected: true,
@@ -254,9 +257,13 @@ describe("session workspace path actions", () => {
sidebarContent: null,
sessions: {
listFiles: vi.fn().mockResolvedValue(result),
getFile: vi.fn().mockResolvedValue({
...result,
file: { ...entry, previewKind: "text", contentEncoding: "utf8", content: "# Readme" },
getFile: vi.fn(async () => {
fileRequested.resolve();
await fileRead.promise;
return {
...result,
file: { ...entry, previewKind: "text", contentEncoding: "utf8", content: "# Readme" },
};
}),
},
} as unknown as SessionWorkspaceHost;
@@ -271,9 +278,16 @@ describe("session workspace path actions", () => {
assert(preview);
preview.focus();
preview.click();
await vi.waitFor(() =>
expect(state.sessionWorkspaceState?.previews[0]?.content.kind).toBe("file"),
);
const opened = state.sessionWorkspaceState?.previews[0];
assert(opened?.readResult);
await fileRequested.promise;
expect(opened.content.kind).toBe("loading");
fileRead.resolve();
// This receipt includes the lazy content import, file read, and current-preview publication.
await opened.readResult;
expect(state.sessionWorkspaceState?.previews[0]).toBe(opened);
expect(opened.content.kind).toBe("file");
expect(state.sessions.getFile).toHaveBeenCalledOnce();
renderRows();
expect(container.querySelector(".chat-workspace-rail__file--active")?.textContent).toContain(
"README.md",
@@ -549,6 +549,7 @@ describe("chat sidebar region", () => {
replacements: new Map(),
accessories: new Map(),
widgets: new Map(),
inlineViews: new Map(),
},
selections: new Map(),
};
@@ -128,6 +128,45 @@ describe("dashboard default activation and personal layout persistence", () => {
expect(pane.pageBoardWidgetMenu(expanded)).toBeUndefined();
});
it("admits relocated widget actions only through the current mounted board", async () => {
await import("../../components/board/board-view.tsx");
const { pane, state } = createDashboardHarness();
state.sidebarLayout = openDashboardPresentation({ columns: [] }, "expanded");
const board = { ...pane.resolveBoardView(), activeTabId: "research" };
const resolveBoard = vi.spyOn(pane, "resolveBoardView").mockReturnValue(board);
const menu = expectDefined(pane.pageBoardWidgetMenu(state.sidebarLayout), "page menu");
const selectItem = vi.fn();
const view = Object.assign(document.createElement("openclaw-board-view"), {
snapshot: board.snapshot,
session: { sessionKey: key, agentId: "main" },
selectPageWidgetMenuItem: selectItem,
});
Reflect.set(pane, "boardView", view);
menu.onSelect("remove");
expect(selectItem).not.toHaveBeenCalled();
document.body.append(view);
menu.onSelect("remove");
expect(selectItem).toHaveBeenCalledExactlyOnceWith(
menu.widget.name,
menu.widget.revision,
"remove",
);
resolveBoard.mockReturnValue({ ...board, provider: { ...board.provider } });
menu.onSelect("remove");
resolveBoard.mockReturnValue({
...board,
snapshot: {
...board.snapshot,
widgets: board.snapshot.widgets.map((widget) => ({ ...widget })),
},
});
menu.onSelect("remove");
resolveBoard.mockReturnValue(board);
view.session = { ...view.session, sessionKey: "agent:main:replacement" };
menu.onSelect("remove");
expect(selectItem).toHaveBeenCalledTimes(1);
});
it("opens the personal expanded preference through the registered keyboard handler", () => {
const h = createDashboardHarness({
savedLayout: {
+32
View File
@@ -17,6 +17,38 @@ function msg(role: string, content: unknown, extra: Record<string, unknown> = {}
const header = "# Chat with OpenClaw\n\n";
const transcript = (messages: unknown[]) => buildChatMarkdown(messages, "OpenClaw");
it("exports saved inline summaries for direct and nested results, without component payloads", () => {
const view = {
type: "plugin_view",
pluginId: "incidents",
viewId: "incident",
version: 7,
payload: { incidentId: "private-reference" },
summary: "Incident is investigating.",
toolCallId: "call",
toolName: "show_incident",
};
const markdown = buildChatMarkdown(
[
{
role: "toolResult",
runId: "first",
details: { pluginView: view },
content: [{ type: "text", text: view.summary }],
},
{
role: "assistant",
runId: "second",
content: [{ type: "toolResult", details: { pluginView: view } }],
},
],
"OpenClaw",
);
expect(markdown?.match(/Incident is investigating\./g)).toHaveLength(2);
expect(markdown).not.toContain("private-reference");
expect(markdown).not.toContain("plugin_view");
});
it.each([
["empty", []],
["tool-only", [toolCall]],
+12 -2
View File
@@ -3,6 +3,7 @@ import { t } from "../../i18n/index.ts";
import { extractTextCached } from "../../lib/chat/message-extract.ts";
import { normalizeRoleForGrouping, resolveMessageRole } from "../../lib/chat/message-normalizer.ts";
import { visibleChatHistoryMessages } from "../../lib/chat/message-visibility.ts";
import { pluginInlineViewItems } from "../../lib/chat/plugin-inline-views.ts";
import { resolveMessageSenderLabel } from "../../lib/chat/sender-label.ts";
import { downloadTextFile } from "../../lib/download.ts";
@@ -20,8 +21,17 @@ export function exportChatMarkdown(messages: unknown[], assistantName: string):
export function buildChatMarkdown(messages: unknown[], assistantName: string): string | null {
const history = visibleChatHistoryMessages(messages);
const lines: string[] = [];
for (const msg of history) {
const content = extractTextCached(msg) ?? "";
const seen = new Set<string>();
for (const [index, msg] of history.entries()) {
const text = extractTextCached(msg) ?? "";
const views = pluginInlineViewItems(
{ kind: "message", key: "export:" + index, message: msg },
seen,
);
const summaries = views
.map((view) => extractTextCached(view.message) ?? "")
.filter((summary) => summary !== text.trim());
const content = summaries.length ? [text, ...summaries].filter(Boolean).join("\n\n") : text;
if (!content.trim()) {
continue;
}
@@ -94,7 +94,10 @@ suite.define(() => {
const response = await page.goto(url.href);
expect(response?.status()).toBe(200);
expect(errors).toEqual([]);
const confirmation = await waitForConfirmModal(page, ".gateway-switch");
const confirmation = await waitForConfirmModal(
page,
page.locator("openclaw-modal-dialog").filter({ has: page.locator(".gateway-switch") }),
);
await confirmation
.getByRole("button", { name: "Switch to gateway.example", exact: true })
.click();
+2
View File
@@ -27,6 +27,7 @@ export type ControlUiContributions = {
actions: ControlUiAction;
accessories: ControlUiAccessory;
widgets: ControlUiWidget;
inlineViews: Parameters<ControlUiHost["ui"]["registerInlineView"]>[0];
replacements: ControlUiReplacement;
};
@@ -58,6 +59,7 @@ export type ControlUiPluginCapability = {
readonly registryStatus: "pending" | "complete" | "failed";
readonly canReload: boolean;
isLoading: (pluginId: string) => boolean;
inlineViewLabel: (key: string) => string | undefined;
reload: () => Promise<void>;
refresh: () => Promise<void>;
subscribe: (listener: () => void) => ControlUiDisposer;
+7
View File
@@ -5,6 +5,7 @@ import type {
ControlUiPageNavigationOptions,
ControlUiPageTarget,
} from "../../../src/plugin-sdk/control-ui.js";
import { isPluginInlineViewVersion } from "../../../src/shared/plugin-inline-view.js";
import { serializeSidebarEntry } from "../app-navigation.ts";
import { isRouteId, pathForRoute, pluginTabLocation } from "../app-route-paths.ts";
import type { ApplicationContext } from "../app/context.ts";
@@ -355,6 +356,12 @@ export function createControlUiPluginHost(
registerAction: (value) => runtime.register(owner, "actions", value),
registerAccessory: (value) => runtime.register(owner, "accessories", value),
registerWidget: (value) => runtime.register(owner, "widgets", value),
registerInlineView: (value) => {
if (!isPluginInlineViewVersion(value.version)) {
throw new Error("Inline view version must be an integer from 1 to 65535.");
}
return runtime.register(owner, "inlineViews", value, value.id + "@" + value.version);
},
registerReplacement: (value) => runtime.register(owner, "replacements", value),
selectReplacement(surface, id) {
current();
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { ControlUiViewContext } from "../../../src/plugin-sdk/control-ui.js";
import { PRESENTATION_CHANGED_EVENT } from "../lib/presentation.ts";
import { createInitializationContext } from "../pages/chat/chat-pane.test-support.ts";
import { threadFixture } from "../pages/chat/components/chat-transcript-fixture.test-support.tsx";
import {
installTranscriptDomMocks,
resetTranscriptTestDom,
threadProps,
} from "../pages/chat/components/chat-transcript.test-support.ts";
import { render } from "../pages/chat/test-helpers/chat-transcript-native.tsx";
import { flush, waitForSolid } from "../test-helpers/solid-settle.ts";
import { createViewRuntime, replacement } from "./control-ui-view.test-support.ts";
beforeEach(installTranscriptDomMocks);
afterEach(resetTranscriptTestDom);
it("retires inline views when retained rows hide and remounts equal props on return", async () => {
const contexts: ControlUiViewContext<unknown>[] = [];
const dispose = vi.fn();
const runtime = createViewRuntime(
replacement("workspace", (container, context) => {
contexts.push(context);
container.textContent = "Incident detail";
return { dispose };
}),
);
const registration = runtime.context.plugins.selectedReplacement("workspace")!;
Object.assign(runtime.context.plugins, {
selectedReplacement: () => undefined,
isLoading: () => false,
inlineViewLabel: () => "Incident",
registrations: (kind: string) =>
kind === "inlineViews" ? [{ ...registration, key: "review/fixture@1" }] : [],
});
const applicationContext = createInitializationContext();
Object.assign(applicationContext, { plugins: runtime.context.plugins });
const presentation = new EventTarget();
let visible = true;
const props = threadProps(
"retained-inline",
"agent:main:incident",
[
{
role: "toolResult",
toolName: "show_incident",
toolCallId: "incident-call",
timestamp: 1_000,
content: [{ type: "text", text: "Incident summary" }],
details: {
pluginView: {
type: "plugin_view",
toolCallId: "incident-call",
toolName: "show_incident",
pluginId: "review",
viewId: "fixture",
version: 1,
payload: { incidentId: "INC-42" },
summary: "Incident summary",
},
},
},
],
{
applicationContext,
presented: true,
transcriptVisible: { owner: presentation, isPresented: () => visible },
},
);
const { transcript, container, rerender } = threadFixture(props);
try {
rerender();
transcript.start();
await waitForSolid(() => expect(contexts).toHaveLength(1));
const row = container.querySelector(".chat-virtual-row");
const placeholder = container.querySelector(".chat-plugin-inline-view");
expect(row).not.toBeNull();
expect(placeholder?.textContent).toContain("Incident detail");
const original = contexts[0]!;
await expect(original.host.request("fixture.active")).resolves.toEqual({ ok: true });
runtime.request.mockClear();
// Keep row props and the last near-viewport receipt unchanged. The canonical
// transcript presentation owner must retire work before another IO receipt.
visible = false;
presentation.dispatchEvent(new Event(PRESENTATION_CHANGED_EVENT));
flush();
await waitForSolid(() => expect(dispose).toHaveBeenCalledOnce());
expect(original.signal.aborted).toBe(true);
expect(placeholder?.querySelector("openclaw-plugin-view")).toBeNull();
await expect(original.host.request("fixture.hidden")).rejects.toThrow("view has ended");
expect(runtime.request).not.toHaveBeenCalled();
visible = true;
presentation.dispatchEvent(new Event(PRESENTATION_CHANGED_EVENT));
flush();
await waitForSolid(() => expect(contexts).toHaveLength(2));
expect(container.querySelector(".chat-virtual-row")).toBe(row);
expect(container.querySelector(".chat-plugin-inline-view")).toBe(placeholder);
expect(placeholder?.textContent).toContain("Incident detail");
expect(contexts[1]?.props).toEqual(original.props);
expect(contexts[1]?.signal.aborted).toBe(false);
await expect(original.host.request("fixture.retired")).rejects.toThrow("view has ended");
expect(runtime.request).not.toHaveBeenCalled();
await expect(contexts[1]!.host.request("fixture.successor")).resolves.toEqual({ ok: true });
render(null, container);
transcript.dispose();
await waitForSolid(() => expect(dispose).toHaveBeenCalledTimes(2));
expect(contexts[1]?.signal.aborted).toBe(true);
} finally {
render(null, container);
transcript.dispose();
}
});
+89
View File
@@ -0,0 +1,89 @@
import { createMemo, createSignal, Show, useContext } from "solid-js";
import type { PluginInlineViewBlock } from "../../../src/shared/plugin-inline-view.js";
import { useOptionalApplication } from "../lib/reactive/context.ts";
import { t } from "../lib/reactive/i18n.ts";
import { createIntersectionObserver, createResizeObserver } from "../lib/reactive/observers.ts";
import { projectSource } from "../lib/reactive/projection.ts";
import { ContentPresentation } from "../lib/solid/presentation.ts";
import { CustomPluginUiDisabled } from "./control-ui-disabled.solid.tsx";
import { PluginContribution } from "./control-ui-view.solid.tsx";
/** Rows own only a small placeholder offscreen, never a parked plugin/component cache. */
export function PluginInlineView(props: {
block: PluginInlineViewBlock;
sessionKey: string;
agentId?: string;
presented?: boolean;
}) {
const app = useOptionalApplication();
const runtime = projectSource(app?.plugins, {
read: (source) => source,
subscribe: (source, publish) => source?.subscribe(publish) ?? (() => {}),
equality: "revision",
});
const [near, setNear] = createSignal(false);
const [height, setHeight] = createSignal(160);
const contentPresented = useContext(ContentPresentation);
// Retained rows can hide and resume without changing their prepared props.
const active = () => near() && props.presented !== false && contentPresented();
let root!: HTMLDivElement;
createIntersectionObserver(
() => [root],
([entry]) => setNear(entry?.isIntersecting === true),
{ rootMargin: "400px 0px" },
);
createResizeObserver(
() => [root],
([entry]) => {
if (active() && entry && entry.contentRect.height > 0) {
setHeight(entry.contentRect.height);
}
},
);
const key = () => props.block.pluginId + "/" + props.block.viewId + "@" + props.block.version;
const status = createMemo(() => {
const owner = runtime.read();
if (owner?.isLoading(props.block.pluginId)) {
return "loading";
}
return owner?.registrations("inlineViews").some((entry) => entry.key === key())
? "ok"
: "unavailable";
});
const notice = () => (
<div class="card" role="status">
<strong>{runtime.read()?.inlineViewLabel(key()) ?? props.block.viewId}</strong>
<p class="muted">
{t(status() === "loading" ? "pluginUi.inlineLoading" : "pluginUi.inlineUnavailable")}
</p>
<CustomPluginUiDisabled context={app} pluginId={props.block.pluginId} />
</div>
);
return (
<div
class="chat-plugin-inline-view"
data-plugin-inline-view={props.block.toolCallId}
ref={(element) => {
root = element;
}}
style={{ "min-height": active() ? undefined : height() + "px" }}
>
<Show when={active()}>
<Show when={status() === "ok"} fallback={notice()}>
<PluginContribution
kind="inlineViews"
contributionKey={key()}
props={{
payload: props.block.payload,
toolCallId: props.block.toolCallId,
toolName: props.block.toolName,
sessionKey: props.sessionKey,
agentId: props.agentId,
}}
fallback={notice}
/>
</Show>
</Show>
</div>
);
}
+1
View File
@@ -45,6 +45,7 @@ it.each(["load", "error", "abort"] as const)(
actions: new Map(),
accessories: new Map(),
widgets: new Map(),
inlineViews: new Map(),
replacements: new Map(),
},
selections: new Map(),
+42
View File
@@ -90,6 +90,48 @@ const pluginDescriptor = (revision = "one", entryUrl = "/review.js") => ({
styles: [],
});
it("dispatches only explicitly advertised inline versions and restores saved version eligibility", async () => {
const first = { pluginId: "review", id: "incident", version: 1, label: "Incident" };
let inlineViews = [first, { ...first, version: 2 }];
const mounts = [vi.fn(() => undefined), vi.fn(() => undefined)];
const client = {
gatewayUrl: localGateway(),
request: async () => ({ ...catalog([pluginDescriptor()]), inlineViews }),
};
const context = connectedContext(client, configForRuntime());
vi.mocked(controlUiLoader.initializeControlUiPlugin).mockImplementation(
async (getContext, runtime, owner) => {
const host = createControlUiPluginHost(getContext, runtime, owner);
host.ui.registerInlineView({ id: "incident", version: 1, mount: mounts[0]! });
host.ui.registerInlineView({ id: "incident", version: 2, mount: mounts[1]! });
expect(() =>
host.ui.registerInlineView({ id: "incident", version: 1, mount: mounts[0]! }),
).toThrow("duplicate");
expect(() =>
host.ui.registerInlineView({ id: "invalid", version: 0, mount: mounts[0]! }),
).toThrow("version");
return Object.assign(owner, { host });
},
);
const runtime = runtimeFor(context);
runtime.start();
await runtime.refresh();
expect(
runtime.registrations("inlineViews").map((entry) => [entry.key, entry.value.mount]),
).toEqual([
["review/incident@1", mounts[0]],
["review/incident@2", mounts[1]],
]);
inlineViews = [{ ...first, version: 3 }];
await runtime.refresh();
expect(runtime.registrations("inlineViews")).toEqual([]);
inlineViews = [first];
await runtime.refresh();
expect(runtime.registrations("inlineViews").map((entry) => entry.key)).toEqual([
"review/incident@1",
]);
});
it.each([false, true])(
"reports one completed catalog without delaying healthy plugins (superseded: %s)",
async (superseded) => {
+24 -2
View File
@@ -59,6 +59,7 @@ const UI_CAPABILITY_BY_CONTRIBUTION = {
actions: "action",
accessories: "accessory",
widgets: "widget",
inlineViews: "inline",
replacements: "replacement",
} as const satisfies Record<
keyof ControlUiContributions,
@@ -69,6 +70,7 @@ const CONTRIBUTION_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const ACTIVATION_TIMEOUT_MS = 15_000;
export class ControlUiPluginRuntime implements ControlUiPluginCapability {
private readonly inlineLabels = new Map<string, string>();
private readonly owners = new Map<string, ControlUiPluginOwner>();
private readonly preloads = new ControlUiPluginAssetPreloads();
private readonly selected = new Map<ControlUiSurface, { key: string; signal: AbortSignal }>();
@@ -144,6 +146,10 @@ export class ControlUiPluginRuntime implements ControlUiPluginCapability {
: "pending";
}
inlineViewLabel(key: string): string | undefined {
return this.inlineLabels.get(key);
}
isLoading(pluginId: string): boolean {
return (
this.loadingCatalog === "pending" ||
@@ -216,6 +222,7 @@ export class ControlUiPluginRuntime implements ControlUiPluginCapability {
return;
}
this.retireOwners();
this.inlineLabels.clear();
this.client = client;
this.connectionId = connectionId;
this.diagnostics = [];
@@ -246,6 +253,7 @@ export class ControlUiPluginRuntime implements ControlUiPluginCapability {
this.loadingOwners.clear();
this.catalogStatus = "pending";
this.loadingCatalog = "pending";
this.inlineLabels.clear();
this.publish();
const current = () =>
!this.disposed && this.client === client && this.refreshGeneration === generation;
@@ -263,6 +271,12 @@ export class ControlUiPluginRuntime implements ControlUiPluginCapability {
if (!current()) {
return;
}
for (const descriptor of catalog.inlineViews ?? []) {
this.inlineLabels.set(
descriptor.pluginId + "/" + descriptor.id + "@" + descriptor.version,
descriptor.label,
);
}
this.diagnostics = catalog.diagnostics;
this.catalogStatus = "complete";
const installed = new Set(catalog.plugins.map((plugin) => plugin.pluginId));
@@ -393,6 +407,7 @@ export class ControlUiPluginRuntime implements ControlUiPluginCapability {
replacements: new Map(),
accessories: new Map(),
widgets: new Map(),
inlineViews: new Map(),
},
selections: new Map<ControlUiSurface, string | null>(),
};
@@ -503,14 +518,15 @@ export class ControlUiPluginRuntime implements ControlUiPluginCapability {
owner: Omit<ControlUiPluginOwner, "host">,
kind: K,
value: ControlUiContributions[K],
registrationId = value.id,
): ControlUiDisposer {
if (!this.isCurrent(owner)) {
throw new Error("This plugin UI activation has ended.");
}
const entries = owner.contributions[kind];
// A disposer owns the registered ID even if the plugin later mutates its definition.
const id = value.id;
if (!CONTRIBUTION_ID.test(id) || entries.has(id)) {
const id = registrationId;
if (!CONTRIBUTION_ID.test(value.id) || entries.has(id)) {
throw new Error(`Invalid or duplicate plugin UI contribution: ${id}`);
}
const abort = new AbortController();
@@ -546,6 +562,12 @@ export class ControlUiPluginRuntime implements ControlUiPluginCapability {
const values: ControlUiRegistration<ControlUiContributions[K]>[] = [];
for (const owner of this.owners.values()) {
for (const [id, entry] of owner.contributions[kind]) {
if (
kind === "inlineViews" &&
!this.inlineLabels.has(owner.descriptor.pluginId + "/" + id)
) {
continue;
}
values.push({
key: `${owner.descriptor.pluginId}/${id}`,
pluginId: owner.descriptor.pluginId,
+32 -7
View File
@@ -26,7 +26,7 @@ import type { ControlUiRegistration } from "./control-ui-capability.ts";
import { observeSolidBridgeProperties } from "./control-ui-properties.ts";
import { scopeControlUiHost } from "./control-ui-scope.ts";
type ViewKind = "pages" | "panels" | "accessories" | "widgets" | "replacements";
type ViewKind = "pages" | "panels" | "accessories" | "widgets" | "inlineViews" | "replacements";
type ViewRegistration = ControlUiRegistration<{ mount: ControlUiView<unknown> }>;
type PluginViewProps = {
@@ -34,6 +34,7 @@ type PluginViewProps = {
contributionKey: string;
surface: ControlUiSurface;
props: unknown;
fallback?: () => JSX.Element;
mountDefaultView: ((target: HTMLElement) => () => void) | undefined;
mountReplacementCompanion: ((target: HTMLElement) => () => void) | undefined;
presented: boolean;
@@ -121,10 +122,14 @@ function PluginViewContent(props: PluginViewProps, host: PluginViewElement) {
return;
}
// SAFETY: the prior bridge value is a host-rendered SDK props record; page props may omit session identity.
const previous = before as Partial<BoardGetParams> | undefined;
const previous = before as (Partial<BoardGetParams> & { toolCallId?: string }) | undefined;
// SAFETY: the next bridge value follows the same host-rendered SDK props contract.
const next = after as Partial<BoardGetParams> | undefined;
if (previous?.sessionKey !== next?.sessionKey || previous?.agentId !== next?.agentId) {
const next = after as (Partial<BoardGetParams> & { toolCallId?: string }) | undefined;
if (
previous?.sessionKey !== next?.sessionKey ||
previous?.agentId !== next?.agentId ||
(host.kind === "inlineViews" && previous?.toolCallId !== next?.toolCallId)
) {
ownerChanged = true;
mountAbort?.abort();
}
@@ -230,6 +235,11 @@ function PluginViewContent(props: PluginViewProps, host: PluginViewElement) {
listen(registration.signal, "abort", unmount, { once: true, signal: abort.signal });
try {
viewContext = {
reportError: (failure) => {
if (mountAbort === abort && !abort.signal.aborted && host.isConnected && !disposed) {
fail(failure);
}
},
host: scopeControlUiHost(registration.host, abort.signal, () => host.isConnected),
signal: abort.signal,
props: scopedProps(abort.signal),
@@ -254,7 +264,13 @@ function PluginViewContent(props: PluginViewProps, host: PluginViewElement) {
};
},
};
handle = registration.value.mount(container, viewContext);
const mounted = registration.value.mount(container, viewContext);
// A plugin can report a synchronous failure before returning its disposer.
if (abort.signal.aborted) {
mounted?.dispose?.();
} else {
handle = mounted;
}
} catch (failure) {
fail(failure);
}
@@ -310,7 +326,12 @@ function PluginViewContent(props: PluginViewProps, host: PluginViewElement) {
</Show>
<Show
when={available() && !error()}
fallback={<MountedContent mount={props.mountDefaultView} />}
fallback={
<>
{props.fallback?.()}
<MountedContent mount={props.mountDefaultView} />
</>
}
>
<Show when={!delegated()}>
<MountedContent mount={props.mountReplacementCompanion} />
@@ -332,6 +353,7 @@ export const PluginView = defineSolidBridge<
contributionKey: { default: "", attribute: false },
surface: { default: "workspace", attribute: false },
props: { default: {}, attribute: false },
fallback: { default: undefined, attribute: false },
mountDefaultView: { default: undefined, attribute: false },
mountReplacementCompanion: { default: undefined, attribute: false },
presented: { default: true, type: Boolean },
@@ -383,7 +405,8 @@ export function PluginSurface<S extends ControlUiSurface>(props: {
return <span style={{ display: "contents" }}>{props.renderDefault()}</span>;
}
const mountDefault = (target: HTMLElement) => mount(target, DefaultView);
const mountCompanion = (target: HTMLElement) => mount(target, () => props.renderCompanion?.());
const mountCompanion = (target: HTMLElement) =>
mount(target, () => <>{props.renderCompanion?.()}</>);
function ReplacementView() {
let host!: HTMLElement;
onSettled(() => props.replacementRef?.(host));
@@ -413,10 +436,12 @@ export function PluginContribution(props: {
contributionKey: string;
props: unknown;
presented?: boolean;
fallback?: () => JSX.Element;
}) {
return (
<PluginView
kind={props.kind}
fallback={props.fallback}
contributionKey={props.contributionKey}
props={props.props}
presented={props.presented ?? true}
+37
View File
@@ -467,6 +467,43 @@ describe("native UI built-in delegation", () => {
expect(navigate).toHaveBeenCalledOnce();
});
it("reports fatal async errors only for the current mount and disposes synchronous failures", async () => {
const contexts: ControlUiViewContext<ControlUiSurfaceProps["workspace"]>[] = [];
const dispose = vi.fn();
let failSynchronously = true;
const { host, reportError } = mountSurface({
id: "fatal",
label: "Fatal",
surface: "workspace",
mount(container, context) {
contexts.push(context);
container.textContent = "Current plugin content";
if (failSynchronously) {
context.reportError?.(new Error("Initial load failed"));
}
return { dispose };
},
});
await vi.waitFor(() =>
expect(host.querySelector("[role=alert]")?.textContent).toContain("Initial load failed"),
);
expect(dispose).toHaveBeenCalledOnce();
expect(contexts[0]?.signal.aborted).toBe(true);
failSynchronously = false;
host.querySelector<HTMLButtonElement>("[role=alert] button")!.click();
await vi.waitFor(() => expect(contexts).toHaveLength(2));
contexts[0]?.reportError?.(new Error("Late retired error"));
flush();
expect(host.querySelector("[role=alert]")).toBeNull();
contexts[1]?.reportError?.(new Error("Current request failed"));
await vi.waitFor(() =>
expect(host.querySelector("[role=alert]")?.textContent).toContain("Current request failed"),
);
expect(contexts[1]?.signal.aborted).toBe(true);
expect(dispose).toHaveBeenCalledTimes(2);
expect(reportError).toHaveBeenCalledTimes(2);
});
it("aborts an invalidated view that fails during update and remounts it on retry", async () => {
const roots: HTMLElement[] = [];
const signals: AbortSignal[] = [];
@@ -47,6 +47,7 @@ it.each([false, true])("AppSidebar replacement sessions (delegates: %s)", async
actions: new Map(),
accessories: new Map(),
widgets: new Map(),
inlineViews: new Map(),
replacements: new Map(),
},
selections: new Map(),
+10 -4
View File
@@ -54,13 +54,18 @@ async function openSessionActions(pane: Locator): Promise<Locator> {
return menu;
}
export async function openChatPanels(scope: Page | Locator): Promise<Locator> {
export async function openChatPanels(
scope: Page | Locator,
presentation: "mobile" | "desktop",
): Promise<Locator> {
const pane = nativePane(scope);
const headers = ".agent-chat__header--mobile, .sidebar-region__header, [data-region-header]";
await pane.locator(headers).filter({ visible: true }).first().waitFor();
await waitForLayoutSettled(pane.page(), "openclaw-chat-pane :is(" + headers + ")");
if (await pane.locator(".agent-chat__header--mobile").isVisible()) {
// The scenario owns its target layout; old controls may remain visible during a viewport change.
if (presentation === "mobile") {
const trigger = pane.locator(".agent-chat__header--mobile .chat-header-session-menu__trigger");
await trigger.waitFor({ state: "visible" });
if (!(await pane.locator(".chat-mobile-host").isVisible())) {
await selectMenuItem(await openSessionActions(pane), "Panels");
}
@@ -69,6 +74,7 @@ export async function openChatPanels(scope: Page | Locator): Promise<Locator> {
const trigger = pane
.locator(".sidebar-region__panels-toggle")
.getByRole("button", { name: "Panels", exact: true });
await trigger.waitFor({ state: "visible" });
if ((await trigger.getAttribute("aria-expanded")) !== "true") {
await trigger.click();
}
@@ -118,7 +124,7 @@ async function selectNativeLayoutAction(pane: Locator, name: string | RegExp): P
.getByRole("button", { name: "Close", exact: true })
.click();
} else if (mobile) {
await openChatPanels(pane);
await openChatPanels(pane, "mobile");
} else {
await pane
.locator(".sidebar-region__panels-toggle")
@@ -144,7 +150,7 @@ async function selectNativeLayoutAction(pane: Locator, name: string | RegExp): P
] as const;
const panel = panels.find(([, action]) => matches(action));
if (panel) {
await openChatPanels(pane);
await openChatPanels(pane, mobile ? "mobile" : "desktop");
if (mobile) {
await host.getByRole("button", { name: "Panels", exact: true }).click();
}
@@ -3,7 +3,8 @@ import { expect, it } from "vitest";
import { openChatSidePanelType } from "../e2e/chat-side-panel.test-support.ts";
import { controlUiE2eBuiltModuleRequest } from "../e2e/control-ui-built-module.test-support.ts";
import { createControlUiE2eSuite } from "../e2e/control-ui-e2e-suite.test-support.ts";
import { waitForControlUiGatewayReady } from "./control-ui-e2e-readiness.ts";
import { openChatPanels } from "./chat-layout-menu.ts";
import { waitForConfirmModal, waitForControlUiGatewayReady } from "./control-ui-e2e-readiness.ts";
import {
defaultControlUiFeatureMethods,
installMockGateway,
@@ -23,6 +24,110 @@ function trackReadinessRequests(page: Page) {
}
suite.define(() => {
it.each(["exec-approval-card", "gateway-switch", "session-worker-stop"])(
"waits for delayed %s confirmation content rather than an unrelated open modal",
async (contentClass) => {
await suite.withPage({}, async ({ page }) => {
// Exercise the helper's rendered-host contract without booting an unrelated page.
// DOM order is not modal ownership: an existing detail dialog may follow the confirm host.
await page.setContent(
'<div id="pending-confirm"></div>' +
'<openclaw-modal-dialog id="details"><dialog open>Channel details</dialog></openclaw-modal-dialog>' +
'<button id="publish">Render confirmation</button>',
);
await page.evaluate((content) => {
document.querySelector("#publish")!.addEventListener("click", () => {
document.querySelector("#pending-confirm")!.innerHTML =
'<openclaw-modal-dialog id="confirmation"><dialog open>' +
'<div class="' +
content +
'"><div>Confirm logout</div>' +
"<button>Cancel</button><button>Logout</button></div></dialog></openclaw-modal-dialog>";
});
}, contentClass);
expect(await page.locator("." + contentClass).count()).toBe(0);
const ready = waitForConfirmModal(
page,
page.locator("openclaw-modal-dialog").filter({ has: page.locator("." + contentClass) }),
);
await page.getByRole("button", { name: "Render confirmation", exact: true }).click();
const confirmation = await ready;
expect(await confirmation.getAttribute("id")).toBe("confirmation");
expect(
await confirmation.getByRole("button", { name: "Logout", exact: true }).isVisible(),
).toBe(true);
expect(await page.locator("#details dialog").isVisible()).toBe(true);
});
},
);
it.each([
{ mode: "mobile", stale: false },
{ mode: "desktop", stale: false },
{ mode: "mobile", stale: true },
{ mode: "desktop", stale: true },
] as const)(
"waits for the requested $mode Panels control (previous presentation visible: $stale)",
async ({ mode, stale }) => {
await suite.withPage({}, async ({ page }) => {
await page.setContent(
'<openclaw-chat-pane class="chat-pane-cache__pane--active"></openclaw-chat-pane>' +
'<button id="publish">Render panel controls</button>',
);
await page.evaluate(
({ presentation, previous }) => {
const pane = document.querySelector("openclaw-chat-pane")!;
const renderControls = (mobile: boolean) => {
pane.innerHTML = mobile
? '<div class="agent-chat__header--mobile"><openclaw-chat-header-session-menu>' +
'<button class="chat-header-session-menu__trigger" aria-expanded="false">Session actions</button>' +
'<div class="oc-menu" role="menu" hidden><button role="menuitem">Panels</button></div>' +
'</openclaw-chat-header-session-menu></div><div class="chat-mobile-host" hidden>Panel sheet</div>'
: '<div class="sidebar-region__header">Session</div>' +
'<div class="sidebar-region__panels-toggle"><button aria-expanded="false">Panels</button></div>';
const trigger = pane.querySelector("button")!;
trigger.addEventListener("click", () => {
pane.setAttribute(
"data-trigger-clicks",
String(Number(pane.getAttribute("data-trigger-clicks")) + 1),
);
trigger.setAttribute("aria-expanded", "true");
pane.querySelector(".oc-menu")?.removeAttribute("hidden");
});
pane.querySelector('[role="menuitem"]')?.addEventListener("click", () => {
pane.querySelector(".oc-menu")!.setAttribute("hidden", "");
pane.querySelector(".chat-mobile-host")!.removeAttribute("hidden");
});
};
if (previous) {
renderControls(presentation !== "mobile");
}
document.querySelector("#publish")!.addEventListener("click", () => {
renderControls(presentation === "mobile");
});
},
{ presentation: mode, previous: stale },
);
const pane = page.locator("openclaw-chat-pane");
const target =
mode === "mobile"
? pane.locator(".agent-chat__header--mobile")
: pane.locator(".sidebar-region__panels-toggle");
expect(await target.count()).toBe(0);
const opened = openChatPanels(page, mode);
// The caller owns the intended presentation; an old control can remain visible until commit.
await page.getByRole("button", { name: "Render panel controls", exact: true }).click();
const trigger = await opened;
expect(await trigger.getAttribute("aria-expanded")).toBe("true");
if (mode === "mobile") {
expect(await pane.locator(".chat-mobile-host").isVisible()).toBe(true);
}
await openChatPanels(pane, mode);
expect(await pane.getAttribute("data-trigger-clicks")).toBe("1");
});
},
);
it("resolves navigation only after a delayed roster has rendered", async () => {
await suite.withPage({}, async ({ page }) => {
const key = "agent:main:roster-only";
@@ -93,12 +93,9 @@ export async function waitForControlUiTerminalReady(page: Page): Promise<void> {
/** In-app confirms must reach full opacity before accepting clicks. */
export async function waitForConfirmModal(
page: Page,
contentSelector = ".exec-approval-card",
// Callers with other open dialogs identify their intended modal before waiting for readiness.
modal: Locator = page.locator("openclaw-modal-dialog").last(),
): Promise<Locator> {
const modal = page
.locator("openclaw-modal-dialog")
.filter({ has: page.locator(contentSelector) })
.last();
const dialog = modal.locator("dialog[open]");
await dialog.waitFor({ state: "visible", timeout: controlUiE2eWaitTimeoutMs });
const element = await dialog.elementHandle();
@@ -7,6 +7,7 @@ export type NativeControlUiPluginFixture = {
pluginId: string;
rootDir: string;
source: string;
inlineViews?: readonly { id: string; label: string; version: number }[];
};
const builds = new Map<string, Promise<PluginManifestControlUi>>();
@@ -47,6 +48,14 @@ export async function prepareNativeControlUiPluginFixtures(
revision: plugins.map((entry) => entry.revision).join("-"),
plugins,
diagnostics: [],
inlineViews: fixtures.flatMap((fixture) =>
(fixture.inlineViews ?? []).map((view) => ({
id: view.id,
label: view.label,
version: view.version,
pluginId: fixture.pluginId,
})),
),
},
};
}