diff --git a/docs/plugins/feature-plugins.md b/docs/plugins/feature-plugins.md index c745b535e457..658bd16fabe3 100644 --- a/docs/plugins/feature-plugins.md +++ b/docs/plugins/feature-plugins.md @@ -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**: diff --git a/docs/plugins/manifest/surfaces.md b/docs/plugins/manifest/surfaces.md index 39a7168fbd57..9c0a5dd8a83d 100644 --- a/docs/plugins/manifest/surfaces.md +++ b/docs/plugins/manifest/surfaces.md @@ -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 diff --git a/examples/inline-incident/README.md b/examples/inline-incident/README.md new file mode 100644 index 000000000000..2be4f48976f9 --- /dev/null +++ b/examples/inline-incident/README.md @@ -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. diff --git a/examples/inline-incident/control-ui.css b/examples/inline-incident/control-ui.css new file mode 100644 index 000000000000..c9e7a466f336 --- /dev/null +++ b/examples/inline-incident/control-ui.css @@ -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; +} diff --git a/examples/inline-incident/control-ui.ts b/examples/inline-incident/control-ui.ts new file mode 100644 index 000000000000..560d05533453 --- /dev/null +++ b/examples/inline-incident/control-ui.ts @@ -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(); +let selection: (Session & { incidentId: string }) | undefined; + +function mountIncident( + container: HTMLElement, + initial: ControlUiViewContext, + 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-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-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) { + 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 | 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; + }; + }, +}); diff --git a/examples/inline-incident/index.ts b/examples/inline-incident/index.ts new file mode 100644 index 000000000000..ef86b077c0da --- /dev/null +++ b/examples/inline-incident/index.ts @@ -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" }, + ); + }, +}); diff --git a/examples/inline-incident/openclaw.plugin.json b/examples/inline-incident/openclaw.plugin.json new file mode 100644 index 000000000000..eb9fbe50ee6c --- /dev/null +++ b/examples/inline-incident/openclaw.plugin.json @@ -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" + ] +} diff --git a/examples/inline-incident/package.json b/examples/inline-incident/package.json new file mode 100644 index 000000000000..bfe4f38973bb --- /dev/null +++ b/examples/inline-incident/package.json @@ -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" + } +} diff --git a/extensions/beam/src/mirror.test.ts b/extensions/beam/src/mirror.test.ts index a282d34dbaf3..cea3cff83054 100644 --- a/extensions/beam/src/mirror.test.ts +++ b/extensions/beam/src/mirror.test.ts @@ -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"); }); diff --git a/extensions/clawrouter/browser/pool.test.ts b/extensions/clawrouter/browser/pool.test.ts index 9c1af1c5bc32..fec6be9a5556 100644 --- a/extensions/clawrouter/browser/pool.test.ts +++ b/extensions/clawrouter/browser/pool.test.ts @@ -86,6 +86,7 @@ async function fixture() { registerAction: unused, registerAccessory: unused, registerWidget: unused, + registerInlineView: unused, registerReplacement: unused, selectReplacement: unused, }, diff --git a/extensions/levanto/registration.test.ts b/extensions/levanto/registration.test.ts index 773a491c730f..78035db00e6b 100644 --- a/extensions/levanto/registration.test.ts +++ b/extensions/levanto/registration.test.ts @@ -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(); const cancel = vi.fn(); const { provider, context, probe, fetch } = fixture( diff --git a/extensions/workboard/browser/test/host.ts b/extensions/workboard/browser/test/host.ts index 4c1ade248f67..f4a419618dd8 100644 --- a/extensions/workboard/browser/test/host.ts +++ b/extensions/workboard/browser/test/host.ts @@ -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(), }, diff --git a/packages/gateway-protocol/src/plugin-ui-capabilities.ts b/packages/gateway-protocol/src/plugin-ui-capabilities.ts index b7cb2104ee66..691c2faf8961 100644 --- a/packages/gateway-protocol/src/plugin-ui-capabilities.ts +++ b/packages/gateway-protocol/src/plugin-ui-capabilities.ts @@ -6,6 +6,7 @@ export const PLUGIN_UI_CAPABILITIES = [ "action", "accessory", "widget", + "inline", "replacement", "link-reader", ] as const; diff --git a/packages/gateway-protocol/src/schema/plugins.ts b/packages/gateway-protocol/src/schema/plugins.ts index 54532f645a56..4f450ed472b9 100644 --- a/packages/gateway-protocol/src/schema/plugins.ts +++ b/packages/gateway-protocol/src/schema/plugins.ts @@ -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 }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 856ba2553efb..ca0fb33aebe3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/scripts/check-control-ui-performance.mts b/scripts/check-control-ui-performance.mts index 7f90d5169d38..4eb7049e67d4 100644 --- a/scripts/check-control-ui-performance.mts +++ b/scripts/check-control-ui-performance.mts @@ -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, diff --git a/scripts/pr-lib/wrapper-components.txt b/scripts/pr-lib/wrapper-components.txt index 88318cdf05fc..a7095f0ffd88 100644 --- a/scripts/pr-lib/wrapper-components.txt +++ b/scripts/pr-lib/wrapper-components.txt @@ -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 diff --git a/src/auto-reply/reply/commands-export-session.test.ts b/src/auto-reply/reply/commands-export-session.test.ts index 022c157546f5..0e93f67f275e 100644 --- a/src/auto-reply/reply/commands-export-session.test.ts +++ b/src/auto-reply/reply/commands-export-session.test.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) => { diff --git a/src/gateway/chat-display-projection.canvas.ts b/src/gateway/chat-display-projection.canvas.ts index e9e20010f8fa..29da93ecaf12 100644 --- a/src/gateway/chat-display-projection.canvas.ts +++ b/src/gateway/chat-display-projection.canvas.ts @@ -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 = {}, ): { details: Record | undefined; truncated: boolean } { const record = readRecord(details); if (!record) { return { details: undefined, truncated: false }; } const projected: Record = {}; + 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. diff --git a/src/gateway/chat-display-projection.inline-view.ts b/src/gateway/chat-display-projection.inline-view.ts new file mode 100644 index 000000000000..f877cffc709e --- /dev/null +++ b/src/gateway/chat-display-projection.inline-view.ts @@ -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; + +/** 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; +} diff --git a/src/gateway/chat-display-projection.sanitize.ts b/src/gateway/chat-display-projection.sanitize.ts index 8db9e4e1127d..8e87054659f4 100644 --- a/src/gateway/chat-display-projection.sanitize.ts +++ b/src/gateway/chat-display-projection.sanitize.ts @@ -177,6 +177,10 @@ export function sanitizeChatHistoryContentBlock( return { block, changed: false, truncated: false }; } const entry = { ...(block as Record) }; + // 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) { diff --git a/src/gateway/control-ui-link-readers.test.ts b/src/gateway/control-ui-link-readers.test.ts index 1839b8df534e..5d6300caecfd 100644 --- a/src/gateway/control-ui-link-readers.test.ts +++ b/src/gateway/control-ui-link-readers.test.ts @@ -14,7 +14,10 @@ import { } from "./control-ui-plugin-tabs.js"; import { createGatewayMethodRegistry } from "./methods/registry.js"; -function descriptor(id: string, fields: Partial) { +function descriptor( + id: string, + fields: Partial, +) { 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> = [ + const invalid: Parameters[1][] = [ { linkReader: { ...metadata, hosts: ["*.example"] } }, { linkReader: { ...metadata, hosts: ["https://forge.example"] } }, { linkReader: { ...metadata, pathPattern: "/items/" } }, diff --git a/src/gateway/control-ui-plugin-tabs.test.ts b/src/gateway/control-ui-plugin-tabs.test.ts index b724e963fd6d..b0479b5072ce 100644 --- a/src/gateway/control-ui-plugin-tabs.test.ts +++ b/src/gateway/control-ui-plugin-tabs.test.ts @@ -15,7 +15,10 @@ import { listControlUiPluginWidgetKinds, } from "./control-ui-plugin-tabs.js"; -function tab(pluginId: string, overrides: Partial = {}) { +function tab( + pluginId: string, + overrides: Partial = {}, +) { const descriptor: PluginControlUiDescriptor = { id: "logbook", surface: "tab", @@ -55,7 +58,7 @@ function activateDescriptors( } function activateRoutes( - entries: Array<[string, string, Partial?]>, + entries: Array<[string, string, Parameters[1]?]>, routes: Parameters[1], ) { return activateDescriptors( diff --git a/src/gateway/control-ui-plugin-tabs.ts b/src/gateway/control-ui-plugin-tabs.ts index 1e6e9f2b21b8..cb1075a385ec 100644 --- a/src/gateway/control-ui-plugin-tabs.ts +++ b/src/gateway/control-ui-plugin-tabs.ts @@ -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, diff --git a/src/gateway/server-chat-live-text.ts b/src/gateway/server-chat-live-text.ts index 22f3dabb1eed..190cbfc51d6e 100644 --- a/src/gateway/server-chat-live-text.ts +++ b/src/gateway/server-chat-live-text.ts @@ -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, diff --git a/src/gateway/server-methods/plugins-control-ui.ts b/src/gateway/server-methods/plugins-control-ui.ts index e69cf221db1c..5d364ae562f5 100644 --- a/src/gateway/server-methods/plugins-control-ui.ts +++ b/src/gateway/server-methods/plugins-control-ui.ts @@ -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"), ), diff --git a/src/gateway/session-transcript-catalog.ts b/src/gateway/session-transcript-catalog.ts index fe46b59a7af8..d4f5c04e1141 100644 --- a/src/gateway/session-transcript-catalog.ts +++ b/src/gateway/session-transcript-catalog.ts @@ -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, diff --git a/src/plugin-sdk/control-ui.ts b/src/plugin-sdk/control-ui.ts index a237866d4a3d..ae33117fef74 100644 --- a/src/plugin-sdk/control-ui.ts +++ b/src/plugin-sdk/control-ui.ts @@ -117,6 +117,8 @@ export type ControlUiViewContext>> = { 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; diff --git a/src/plugin-sdk/plugin-test-api.ts b/src/plugin-sdk/plugin-test-api.ts index 901e327d48c9..4c38d06e66c2 100644 --- a/src/plugin-sdk/plugin-test-api.ts +++ b/src/plugin-sdk/plugin-test-api.ts @@ -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", diff --git a/src/plugins/api-builder.ts b/src/plugins/api-builder.ts index 1c961bbd0531..1df4c051291c 100644 --- a/src/plugins/api-builder.ts +++ b/src/plugins/api-builder.ts @@ -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>; @@ -126,6 +127,8 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi ) as Pick; 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, diff --git a/src/plugins/host-hooks.ts b/src/plugins/host-hooks.ts index 2daf5d12c163..8ad3f639c935 100644 --- a/src/plugins/host-hooks.ts +++ b/src/plugins/host-hooks.ts @@ -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; diff --git a/src/plugins/inline-view.test.ts b/src/plugins/inline-view.test.ts new file mode 100644 index 000000000000..2341e4c3c7e3 --- /dev/null +++ b/src/plugins/inline-view.test.ts @@ -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: "" }; + 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(); + }); +}); diff --git a/src/plugins/inline-view.ts b/src/plugins/inline-view.ts new file mode 100644 index 000000000000..da82a1e49c8b --- /dev/null +++ b/src/plugins/inline-view.ts @@ -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>( + 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 }, + }, + }; +} diff --git a/src/plugins/plugin-api.types.ts b/src/plugins/plugin-api.types.ts index 48d226677a4d..699e19688b73 100644 --- a/src/plugins/plugin-api.types.ts +++ b/src/plugins/plugin-api.types.ts @@ -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; diff --git a/src/plugins/registry-control-ui.ts b/src/plugins/registry-control-ui.ts index 3541ef4d480e..6782e618d820 100644 --- a/src/plugins/registry-control-ui.ts +++ b/src/plugins/registry-control-ui.ts @@ -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([ "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 } : {}), diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index 671f9f76813f..40d52204092b 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -431,7 +431,11 @@ export type PluginRegistry = { >; trustedToolPolicies: PluginTrustedToolPolicyRegistryRegistration[]; toolMetadata: Array; - controlUiDescriptors: Array; + controlUiDescriptors: Array< + PluginRegistrationOwner & { + descriptor: PluginControlUiDescriptor; + } + >; runtimeLifecycles: Array< PluginRegistrationOwner & { lifecycle: PluginRuntimeLifecycleRegistration } >; diff --git a/src/plugins/session-catalog-history-import.test.ts b/src/plugins/session-catalog-history-import.test.ts index ec5fa75f527f..611ccef48d79 100644 --- a/src/plugins/session-catalog-history-import.test.ts +++ b/src/plugins/session-catalog-history-import.test.ts @@ -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(); + } + } + }); +}); diff --git a/src/plugins/tool-factory-runtime.ts b/src/plugins/tool-factory-runtime.ts index 81b595b168c6..55e99ec858a5 100644 --- a/src/plugins/tool-factory-runtime.ts +++ b/src/plugins/tool-factory-runtime.ts @@ -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)) } diff --git a/src/shared/plugin-inline-view.ts b/src/shared/plugin-inline-view.ts new file mode 100644 index 000000000000..79bcfb26395d --- /dev/null +++ b/src/shared/plugin-inline-view.ts @@ -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; +} diff --git a/test/scripts/control-ui-performance.test.ts b/test/scripts/control-ui-performance.test.ts index de2090b9c28f..fe95dfb628b0 100644 --- a/test/scripts/control-ui-performance.test.ts +++ b/test/scripts/control-ui-performance.test.ts @@ -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[] = []) => { diff --git a/test/vitest-ui-e2e-config.test.ts b/test/vitest-ui-e2e-config.test.ts index 14bb3e558e5d..aca07a452ea7 100644 --- a/test/vitest-ui-e2e-config.test.ts +++ b/test/vitest-ui-e2e-config.test.ts @@ -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"); diff --git a/test/vitest/vitest.ui-e2e.config.ts b/test/vitest/vitest.ui-e2e.config.ts index aae51bb94bc2..c84f2ac610c3 100644 --- a/test/vitest/vitest.ui-e2e.config.ts +++ b/test/vitest/vitest.ui-e2e.config.ts @@ -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", diff --git a/test/vitest/vitest.ui-paths.mjs b/test/vitest/vitest.ui-paths.mjs index b9844577d67b..56aadfd2027f 100644 --- a/test/vitest/vitest.ui-paths.mjs +++ b/test/vitest/vitest.ui-paths.mjs @@ -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", diff --git a/ui/config/control-ui-boot-modules.json b/ui/config/control-ui-boot-modules.json index db65ea9b8984..538bc3660fae 100644 --- a/ui/config/control-ui-boot-modules.json +++ b/ui/config/control-ui-boot-modules.json @@ -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", diff --git a/ui/src/components/app-sidebar-pages-identity.test.ts b/ui/src/components/app-sidebar-pages-identity.test.ts index fa685b33d783..aaa97ec4c66b 100644 --- a/ui/src/components/app-sidebar-pages-identity.test.ts +++ b/ui/src/components/app-sidebar-pages-identity.test.ts @@ -78,6 +78,7 @@ async function fixture() { actions: [], accessories: [], widgets: [], + inlineViews: [], replacements: [], }; const plugins = Object.assign(context.plugins, { diff --git a/ui/src/components/app-sidebar-rail-menus.browser.test.ts b/ui/src/components/app-sidebar-rail-menus.browser.test.ts index 5c9412dbad26..f33c426c4c48 100644 --- a/ui/src/components/app-sidebar-rail-menus.browser.test.ts +++ b/ui/src/components/app-sidebar-rail-menus.browser.test.ts @@ -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[] = []; + 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(".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 = {}) => { + 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(); + } } }); }); diff --git a/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts b/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts index 75aa4b2c1ded..299c13938ab1 100644 --- a/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts +++ b/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts @@ -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"); diff --git a/ui/src/e2e/chat-panel-layout.e2e.test.ts b/ui/src/e2e/chat-panel-layout.e2e.test.ts index d2c91ba4b3ee..3dd240640725 100644 --- a/ui/src/e2e/chat-panel-layout.e2e.test.ts +++ b/ui/src/e2e/chat-panel-layout.e2e.test.ts @@ -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(); diff --git a/ui/src/e2e/chat-rail-columns.e2e.test.ts b/ui/src/e2e/chat-rail-columns.e2e.test.ts index 9e580d195d4f..12232de71fb0 100644 --- a/ui/src/e2e/chat-rail-columns.e2e.test.ts +++ b/ui/src/e2e/chat-rail-columns.e2e.test.ts @@ -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, diff --git a/ui/src/e2e/control-ui-shell-routing.e2e.test.ts b/ui/src/e2e/control-ui-shell-routing.e2e.test.ts index 70a389b8371c..ff5ddf5df84e 100644 --- a/ui/src/e2e/control-ui-shell-routing.e2e.test.ts +++ b/ui/src/e2e/control-ui-shell-routing.e2e.test.ts @@ -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 diff --git a/ui/src/e2e/mobile-chat-session-menu.e2e.test.ts b/ui/src/e2e/mobile-chat-session-menu.e2e.test.ts index 307a204327cb..fd00827b174d 100644 --- a/ui/src/e2e/mobile-chat-session-menu.e2e.test.ts +++ b/ui/src/e2e/mobile-chat-session-menu.e2e.test.ts @@ -81,7 +81,7 @@ async function withMobilePage(run: (page: Page) => Promise, 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") diff --git a/ui/src/e2e/new-session-page.device-stop.e2e.test.ts b/ui/src/e2e/new-session-page.device-stop.e2e.test.ts index 265d80e7d18f..b5bc9f503df6 100644 --- a/ui/src/e2e/new-session-page.device-stop.e2e.test.ts +++ b/ui/src/e2e/new-session-page.device-stop.e2e.test.ts @@ -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(); diff --git a/ui/src/e2e/plugin-inline-view.e2e.test.ts b/ui/src/e2e/plugin-inline-view.e2e.test.ts new file mode 100644 index 000000000000..7cb6139a182b --- /dev/null +++ b/ui/src/e2e/plugin-inline-view.e2e.test.ts @@ -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); + }, + ); + }); +}); diff --git a/ui/src/e2e/plugin-inline-view.real-gateway.e2e.test.ts b/ui/src/e2e/plugin-inline-view.real-gateway.e2e.test.ts new file mode 100644 index 000000000000..b6e3369e7555 --- /dev/null +++ b/ui/src/e2e/plugin-inline-view.real-gateway.e2e.test.ts @@ -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[] = [ + { servedControlUi: await verifyGatewayServedControlUiBundle(suite.server.baseUrl) }, + ]; + const connected = createDeferredCore(); + 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(); + 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), + ); + } + }); +}); diff --git a/ui/src/e2e/session-dashboard-active-panel.e2e.test.ts b/ui/src/e2e/session-dashboard-active-panel.e2e.test.ts index 7e2be8a1ee5f..8961c94d5330 100644 --- a/ui/src/e2e/session-dashboard-active-panel.e2e.test.ts +++ b/ui/src/e2e/session-dashboard-active-panel.e2e.test.ts @@ -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 }); diff --git a/ui/src/e2e/solo-widget-chrome.e2e.test.ts b/ui/src/e2e/solo-widget-chrome.e2e.test.ts index bf45a7fc46a9..c4237f92530d 100644 --- a/ui/src/e2e/solo-widget-chrome.e2e.test.ts +++ b/ui/src/e2e/solo-widget-chrome.e2e.test.ts @@ -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" }, diff --git a/ui/src/i18n/locales/en-plugin-management.ts b/ui/src/i18n/locales/en-plugin-management.ts index ab7a7c431135..d56d478c827d 100644 --- a/ui/src/i18n/locales/en-plugin-management.ts +++ b/ui/src/i18n/locales/en-plugin-management.ts @@ -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.", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index e199c046f355..742c446bd1c6 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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.", diff --git a/ui/src/lib/chat/chat-types.ts b/ui/src/lib/chat/chat-types.ts index 51288ec38cc6..d9b5b7f6301a 100644 --- a/ui/src/lib/chat/chat-types.ts +++ b/ui/src/lib/chat/chat-types.ts @@ -392,6 +392,7 @@ export type MessageImageSource = { }; export type MessageContentItem = + | import("../../../../src/shared/plugin-inline-view.js").PluginInlineViewBlock | ClawHubRecommendation | { type: "image"; diff --git a/ui/src/lib/chat/message-normalizer.ts b/ui/src/lib/chat/message-normalizer.ts index c7892c05cebb..16ea479c713b 100644 --- a/ui/src/lib/chat/message-normalizer.ts +++ b/ui/src/lib/chat/message-normalizer.ts @@ -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] : []; diff --git a/ui/src/lib/chat/plugin-inline-views.ts b/ui/src/lib/chat/plugin-inline-views.ts new file mode 100644 index 000000000000..192411aee12d --- /dev/null +++ b/ui/src/lib/chat/plugin-inline-views.ts @@ -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; + +/** Each admitted result owns a row; never queue across turns or scan text/guest JSON. */ +export function pluginInlineViewItems(item: MessageItem, seen: Set): 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; +} diff --git a/ui/src/pages/chat/chat-pane-board.ts b/ui/src/pages/chat/chat-pane-board.ts index 303e153b4550..5aa6c8a331e5 100644 --- a/ui/src/pages/chat/chat-pane-board.ts +++ b/ui/src/pages/chat/chat-pane-board.ts @@ -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; diff --git a/ui/src/pages/chat/chat-thread-build.ts b/ui/src/pages/chat/chat-thread-build.ts index e3769aae91bf..3c06d0de0439 100644 --- a/ui/src/pages/chat/chat-thread-build.ts +++ b/ui/src/pages/chat/chat-thread-build.ts @@ -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[1], - fallback: string, -): string { - const identity = canvasPreviewBaseIdentity(message, source); - return identity ? `canvas:${identity}` : `${fallback}:canvas`; -} - export function buildChatItems( props: BuildChatItemsProps, inputOrder: ChatInputOrderState = { keys: [] }, ): Array { let items: ChatItem[] = []; + const inlineInvocations = new Set(); const tools = props.toolMessages.filter( (message): message is Record => asRecord(message) !== null, ); @@ -188,6 +183,14 @@ export function buildChatItems( ); const searchFiltering = props.searchOpen === true && Boolean(props.searchQuery?.trim()); const hiddenHistoryKeys = new Set(); + const hideSearchMiss = (item: Extract) => { + if ( + searchFiltering && + !messageMatchesSearchQuery(item.message, props.searchQuery ?? "", props.messageRecovery) + ) { + hiddenHistoryKeys.add(item.key); + } + }; const canvasIdentities = new Set(); 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) => - 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 diff --git a/ui/src/pages/chat/chat-thread-canvas.ts b/ui/src/pages/chat/chat-thread-canvas.ts new file mode 100644 index 000000000000..7a2783715cb2 --- /dev/null +++ b/ui/src/pages/chat/chat-thread-canvas.ts @@ -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[1], + fallback: string, +): string { + const identity = canvasPreviewBaseIdentity(message, source); + return identity ? `canvas:${identity}` : `${fallback}:canvas`; +} + +export function collectCanvasTurns( + historyItems: Array>, + normalizedHistory: Array, + 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, + hiddenInputKeys: ReadonlySet, +): 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) => + 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), + ); +} diff --git a/ui/src/pages/chat/chat-thread-inline-view.test.ts b/ui/src/pages/chat/chat-thread-inline-view.test.ts new file mode 100644 index 000000000000..68fc0a19744b --- /dev/null +++ b/ui/src/pages/chat/chat-thread-inline-view.test.ts @@ -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[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[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([]); + }); +}); diff --git a/ui/src/pages/chat/components/chat-message-bubble-presentation.ts b/ui/src/pages/chat/components/chat-message-bubble-presentation.ts index 8a32a0a7417f..3a73369776e1 100644 --- a/ui/src/pages/chat/components/chat-message-bubble-presentation.ts +++ b/ui/src/pages/chat/components/chat-message-bubble-presentation.ts @@ -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, diff --git a/ui/src/pages/chat/components/chat-message-bubble-view.tsx b/ui/src/pages/chat/components/chat-message-bubble-view.tsx index 6e6d2ad7cce7..c0cfe374ae0e 100644 --- a/ui/src/pages/chat/components/chat-message-bubble-view.tsx +++ b/ui/src/pages/chat/components/chat-message-bubble-view.tsx @@ -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) { + block.toolCallId}> + {(block) => ( + + )} + false, + inlineViewLabel: () => undefined, reload: async () => undefined, refresh: async () => undefined, registrations: () => [], diff --git a/ui/src/pages/chat/components/chat-session-workspace-rail.test.tsx b/ui/src/pages/chat/components/chat-session-workspace-rail.test.tsx index 6b20a60b3ae1..2165c50f03e3 100644 --- a/ui/src/pages/chat/components/chat-session-workspace-rail.test.tsx +++ b/ui/src/pages/chat/components/chat-session-workspace-rail.test.tsx @@ -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", diff --git a/ui/src/pages/chat/components/chat-sidebar-region.test.ts b/ui/src/pages/chat/components/chat-sidebar-region.test.ts index 7a40101f0662..f032a6e8c641 100644 --- a/ui/src/pages/chat/components/chat-sidebar-region.test.ts +++ b/ui/src/pages/chat/components/chat-sidebar-region.test.ts @@ -549,6 +549,7 @@ describe("chat sidebar region", () => { replacements: new Map(), accessories: new Map(), widgets: new Map(), + inlineViews: new Map(), }, selections: new Map(), }; diff --git a/ui/src/pages/chat/dashboard-presentation-defaults.test.ts b/ui/src/pages/chat/dashboard-presentation-defaults.test.ts index 49ff98843271..5c90a94fc6a2 100644 --- a/ui/src/pages/chat/dashboard-presentation-defaults.test.ts +++ b/ui/src/pages/chat/dashboard-presentation-defaults.test.ts @@ -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: { diff --git a/ui/src/pages/chat/export.test.ts b/ui/src/pages/chat/export.test.ts index 272c92079091..3e4a9c99cceb 100644 --- a/ui/src/pages/chat/export.test.ts +++ b/ui/src/pages/chat/export.test.ts @@ -17,6 +17,38 @@ function msg(role: string, content: unknown, extra: Record = {} 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]], diff --git a/ui/src/pages/chat/export.ts b/ui/src/pages/chat/export.ts index e7a629b016be..bb1cf417cc03 100644 --- a/ui/src/pages/chat/export.ts +++ b/ui/src/pages/chat/export.ts @@ -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(); + 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; } diff --git a/ui/src/pages/workboard/workboard-distribution.e2e.test.ts b/ui/src/pages/workboard/workboard-distribution.e2e.test.ts index f1387a084969..e7d98a149a94 100644 --- a/ui/src/pages/workboard/workboard-distribution.e2e.test.ts +++ b/ui/src/pages/workboard/workboard-distribution.e2e.test.ts @@ -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(); diff --git a/ui/src/plugins/control-ui-capability.ts b/ui/src/plugins/control-ui-capability.ts index c710bbb66aa9..f1e9b6de33ab 100644 --- a/ui/src/plugins/control-ui-capability.ts +++ b/ui/src/plugins/control-ui-capability.ts @@ -27,6 +27,7 @@ export type ControlUiContributions = { actions: ControlUiAction; accessories: ControlUiAccessory; widgets: ControlUiWidget; + inlineViews: Parameters[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; refresh: () => Promise; subscribe: (listener: () => void) => ControlUiDisposer; diff --git a/ui/src/plugins/control-ui-host.ts b/ui/src/plugins/control-ui-host.ts index 607201ad6543..7a4fc80edeef 100644 --- a/ui/src/plugins/control-ui-host.ts +++ b/ui/src/plugins/control-ui-host.ts @@ -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(); diff --git a/ui/src/plugins/control-ui-inline-view.test.tsx b/ui/src/plugins/control-ui-inline-view.test.tsx new file mode 100644 index 000000000000..795b35f1a741 --- /dev/null +++ b/ui/src/plugins/control-ui-inline-view.test.tsx @@ -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[] = []; + 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(); + } +}); diff --git a/ui/src/plugins/control-ui-inline-view.tsx b/ui/src/plugins/control-ui-inline-view.tsx new file mode 100644 index 000000000000..33e92367e626 --- /dev/null +++ b/ui/src/plugins/control-ui-inline-view.tsx @@ -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 = () => ( +
+ {runtime.read()?.inlineViewLabel(key()) ?? props.block.viewId} +

+ {t(status() === "loading" ? "pluginUi.inlineLoading" : "pluginUi.inlineUnavailable")} +

+ +
+ ); + return ( +
{ + root = element; + }} + style={{ "min-height": active() ? undefined : height() + "px" }} + > + + + + + +
+ ); +} diff --git a/ui/src/plugins/control-ui-loader.test.ts b/ui/src/plugins/control-ui-loader.test.ts index 08df792dfbd2..a8b67c214be2 100644 --- a/ui/src/plugins/control-ui-loader.test.ts +++ b/ui/src/plugins/control-ui-loader.test.ts @@ -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(), diff --git a/ui/src/plugins/control-ui-runtime.test.ts b/ui/src/plugins/control-ui-runtime.test.ts index b6d460457936..6b9631ec8355 100644 --- a/ui/src/plugins/control-ui-runtime.test.ts +++ b/ui/src/plugins/control-ui-runtime.test.ts @@ -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) => { diff --git a/ui/src/plugins/control-ui-runtime.ts b/ui/src/plugins/control-ui-runtime.ts index dacc56b7f4dd..f7cb66f22a40 100644 --- a/ui/src/plugins/control-ui-runtime.ts +++ b/ui/src/plugins/control-ui-runtime.ts @@ -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(); private readonly owners = new Map(); private readonly preloads = new ControlUiPluginAssetPreloads(); private readonly selected = new Map(); @@ -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(), }; @@ -503,14 +518,15 @@ export class ControlUiPluginRuntime implements ControlUiPluginCapability { owner: Omit, 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[] = []; 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, diff --git a/ui/src/plugins/control-ui-view.solid.tsx b/ui/src/plugins/control-ui-view.solid.tsx index 444b502a0b35..e90e83ae9e85 100644 --- a/ui/src/plugins/control-ui-view.solid.tsx +++ b/ui/src/plugins/control-ui-view.solid.tsx @@ -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 }>; 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 | undefined; + const previous = before as (Partial & { toolCallId?: string }) | undefined; // SAFETY: the next bridge value follows the same host-rendered SDK props contract. - const next = after as Partial | undefined; - if (previous?.sessionKey !== next?.sessionKey || previous?.agentId !== next?.agentId) { + const next = after as (Partial & { 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) {
} + fallback={ + <> + {props.fallback?.()} + + + } > @@ -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(props: { return {props.renderDefault()}; } 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 ( { expect(navigate).toHaveBeenCalledOnce(); }); + it("reports fatal async errors only for the current mount and disposes synchronous failures", async () => { + const contexts: ControlUiViewContext[] = []; + 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("[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[] = []; diff --git a/ui/src/test-helpers/app-sidebar-cases/plugin-session-list.ts b/ui/src/test-helpers/app-sidebar-cases/plugin-session-list.ts index 085866bf1a86..11f874b82368 100644 --- a/ui/src/test-helpers/app-sidebar-cases/plugin-session-list.ts +++ b/ui/src/test-helpers/app-sidebar-cases/plugin-session-list.ts @@ -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(), diff --git a/ui/src/test-helpers/chat-layout-menu.ts b/ui/src/test-helpers/chat-layout-menu.ts index b179005cf753..9ad8e9663139 100644 --- a/ui/src/test-helpers/chat-layout-menu.ts +++ b/ui/src/test-helpers/chat-layout-menu.ts @@ -54,13 +54,18 @@ async function openSessionActions(pane: Locator): Promise { return menu; } -export async function openChatPanels(scope: Page | Locator): Promise { +export async function openChatPanels( + scope: Page | Locator, + presentation: "mobile" | "desktop", +): Promise { 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 { 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(); } diff --git a/ui/src/test-helpers/control-ui-e2e-readiness.e2e.test.ts b/ui/src/test-helpers/control-ui-e2e-readiness.e2e.test.ts index c4b0e2ce1f93..6f811ceb2ab6 100644 --- a/ui/src/test-helpers/control-ui-e2e-readiness.e2e.test.ts +++ b/ui/src/test-helpers/control-ui-e2e-readiness.e2e.test.ts @@ -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( + '
' + + 'Channel details' + + '', + ); + await page.evaluate((content) => { + document.querySelector("#publish")!.addEventListener("click", () => { + document.querySelector("#pending-confirm")!.innerHTML = + '' + + '
Confirm logout
' + + "
"; + }); + }, 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( + '' + + '', + ); + await page.evaluate( + ({ presentation, previous }) => { + const pane = document.querySelector("openclaw-chat-pane")!; + const renderControls = (mobile: boolean) => { + pane.innerHTML = mobile + ? '
' + + '' + + '' + + '
' + : '' + + ''; + 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"; diff --git a/ui/src/test-helpers/control-ui-e2e-readiness.ts b/ui/src/test-helpers/control-ui-e2e-readiness.ts index a99cdd624871..2c671295bb1a 100644 --- a/ui/src/test-helpers/control-ui-e2e-readiness.ts +++ b/ui/src/test-helpers/control-ui-e2e-readiness.ts @@ -93,12 +93,9 @@ export async function waitForControlUiTerminalReady(page: Page): Promise { /** 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 { - 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(); diff --git a/ui/src/test-helpers/control-ui-plugin-fixture.ts b/ui/src/test-helpers/control-ui-plugin-fixture.ts index d29591fd8d32..ebabb03745f3 100644 --- a/ui/src/test-helpers/control-ui-plugin-fixture.ts +++ b/ui/src/test-helpers/control-ui-plugin-fixture.ts @@ -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>(); @@ -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, + })), + ), }, }; }