diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md
index 13d8dd7580c..4be14149cf6 100644
--- a/packages/plugin-sdk/README.md
+++ b/packages/plugin-sdk/README.md
@@ -52,7 +52,7 @@ UI modules export synchronous `activate(context)` returning `UiActivation`: `{ e
Use `context.application` for application calls through the UI instance’s declared service bindings. It is the native application client, retaining the Client, journal and original receipts. `context.services[importName][index]` selects a particular bound provider, including imports with multiple providers; `context.operations` recovers the instance’s original mutation receipts. `context.host` and `maka/host` expose the full client to trusted UI code, including root and catalog integration. Keep controllers tied to the application client of their owning activation.
-An embedded view declares `{ kind: 'view', contract: 'shared.contract.id' }` and returns a matching implementation with `render({ context, runtime, input })`. Define the shared input type with `defineUiViewContract(id)`. A consumer selects a `UiTarget` (`{ path, id }`) and calls `runtime.renderView(target, contract, input)`; `runtime.view(target, contract)` resolves the contribution and checks its declared kind and contract. The target can come from plugin configuration, allowing another installed UI instance to provide that surface. The contracts in `workbench-views.ts`, `composer-view.ts` and `workspace-view.ts` cover conversation navigation/session/preview, the composer, settings and workspace preview; they specify inputs and ownership callbacks rather than giving consumers the provider’s private state.
+An embedded view declares `{ kind: 'view', contract: 'shared.contract.id' }` and returns a matching implementation with `render({ context, runtime, input })`. Define the shared input type with `defineUiViewContract(id)`. A consumer selects a `UiTarget` (`{ path, id }`) and calls `runtime.renderView(target, contract, input, options?)` (passing `{ loading: false }` when mounting an overlay-only view that should not reserve layout space while loading); `runtime.view(target, contract)` resolves the contribution and checks its declared kind and contract. The target can come from plugin configuration, allowing another installed UI instance to provide that surface. The contracts in `workbench-views.ts`, `composer-view.ts` and `workspace-view.ts` cover conversation navigation/session/preview, the composer, settings and workspace preview; they specify inputs and ownership callbacks rather than giving consumers the provider’s private state.
Commands implement `run({ sessionId, argument? })` and optional `enabled(context)`. Their declarations supply `title` and optional `description`, `shortcut`, `order`, `trigger`, `takesArgument` and `hint` metadata; accepting arguments requires a trigger. Roots can inspect the contribution catalog, use `runtime.commandEnabled(...)` for availability and call `runtime.invoke(...)` to run the command.
diff --git a/packages/plugin-sdk/README.zh-CN.md b/packages/plugin-sdk/README.zh-CN.md
index 9e3e7c62497..0752b39db69 100644
--- a/packages/plugin-sdk/README.zh-CN.md
+++ b/packages/plugin-sdk/README.zh-CN.md
@@ -52,7 +52,7 @@ UI 模块导出同步的 `activate(context)`,返回 `UiActivation`:`{ export
使用 `context.application`,让应用调用经过 UI 实例声明的服务绑定。它是原生应用客户端,保留 Client、journal 和原始 receipt。`context.services[importName][index]` 选择具体的绑定提供者,也支持有多个提供者的导入;`context.operations` 恢复该实例的原始 mutation receipt。可信 UI 代码仍可使用完整的 `context.host` 和 `maka/host` 客户端,包括根界面与目录集成。控制器应持有所属激活实例的应用客户端。
-嵌入视图声明 `{ kind: 'view', contract: 'shared.contract.id' }`,并返回具有 `render({ context, runtime, input })` 的匹配实现。用 `defineUiViewContract(id)` 定义共享输入类型。消费方选择 `UiTarget`(`{ path, id }`),调用 `runtime.renderView(target, contract, input)`;`runtime.view(target, contract)` 解析贡献并检查其声明的种类与契约。目标可以来自插件配置,让其他已安装 UI 实例提供该界面。`workbench-views.ts`、`composer-view.ts` 和 `workspace-view.ts` 中的契约覆盖会话导航、会话界面、会话预览、输入区、设置和工作区预览;它们定义输入及所有权回调,不向消费方暴露提供者的私有状态。
+嵌入视图声明 `{ kind: 'view', contract: 'shared.contract.id' }`,并返回具有 `render({ context, runtime, input })` 的匹配实现。用 `defineUiViewContract(id)` 定义共享输入类型。消费方选择 `UiTarget`(`{ path, id }`),调用 `runtime.renderView(target, contract, input, options?)`(挂载仅呈现浮层的视图时可传入 `{ loading: false }`,避免加载态占位占据布局空间);`runtime.view(target, contract)` 解析贡献并检查其声明的种类与契约。目标可以来自插件配置,让其他已安装 UI 实例提供该界面。`workbench-views.ts`、`composer-view.ts` 和 `workspace-view.ts` 中的契约覆盖会话导航、会话界面、会话预览、输入区、设置和工作区预览;它们定义输入及所有权回调,不向消费方暴露提供者的私有状态。
命令实现 `run({ sessionId, argument? })` 和可选的 `enabled(context)`。声明提供 `title`,以及可选的 `description`、`shortcut`、`order`、`trigger`、`takesArgument` 和 `hint` 元数据;接受参数的命令必须声明触发词。根界面可以查看贡献目录,用 `runtime.commandEnabled(...)` 判断可用性,通过 `runtime.invoke(...)` 执行命令。
diff --git a/packages/plugin-sdk/src/workbench.ts b/packages/plugin-sdk/src/workbench.ts
index 8ff16d6b813..b802b3fe8c5 100644
--- a/packages/plugin-sdk/src/workbench.ts
+++ b/packages/plugin-sdk/src/workbench.ts
@@ -161,6 +161,10 @@ export interface UiContributionProps {
readonly tool?: Omit;
}
+export interface UiViewRenderOptions {
+ readonly loading?: boolean;
+}
+
/** The shared catalog and components; the selected root owns their presentation. */
export interface UiRuntime {
source(): DeploymentRef | null;
@@ -171,7 +175,12 @@ export interface UiRuntime {
settings(): readonly UiContributionRef[];
renderer(presentation: string | null | undefined): UiContributionRef | undefined;
view(target: UiTarget, contract: UiViewContract): UiContributionRef;
- renderView(target: UiTarget, contract: UiViewContract, input: Input): Element;
+ renderView(
+ target: UiTarget,
+ contract: UiViewContract,
+ input: Input,
+ options?: UiViewRenderOptions,
+ ): Element;
render(contribution: UiContributionRef, props: UiContributionProps): Element;
openPanel(target: UiTarget, input?: unknown): void;
commandEnabled(contribution: UiContributionRef, context: UiCommandContext): boolean;
diff --git a/packages/plugin-sdk/tests/application.types.ts b/packages/plugin-sdk/tests/application.types.ts
index d669752cfdb..b36d4c1693c 100644
--- a/packages/plugin-sdk/tests/application.types.ts
+++ b/packages/plugin-sdk/tests/application.types.ts
@@ -536,10 +536,19 @@ function renderSessionView(runtime: UiRuntime, target: UiTarget) {
const rendered: string = runtime.renderView(target, sessionViewContract, {
sessionId: 'session',
});
+ const withoutLoading: string = runtime.renderView(
+ target,
+ sessionViewContract,
+ { sessionId: 'session' },
+ { loading: false },
+ );
// @ts-expect-error The contract token supplies the input type to the renderer.
runtime.renderView(target, sessionViewContract, { sessionId: 42 });
// @ts-expect-error An embedded view cannot omit its contract's required input.
runtime.renderView(target, sessionViewContract, {});
+ // @ts-expect-error Render options are typed booleans, not arbitrary strings.
+ runtime.renderView(target, sessionViewContract, { sessionId: 'session' }, { loading: 'no' });
+ void withoutLoading;
return rendered;
}
void [sessionViewModule, renderSessionView];
diff --git a/plugins/agent/conversations/ui/SessionSurface.tsx b/plugins/agent/conversations/ui/SessionSurface.tsx
index df6c1caef46..ff10d0959b9 100644
--- a/plugins/agent/conversations/ui/SessionSurface.tsx
+++ b/plugins/agent/conversations/ui/SessionSurface.tsx
@@ -2031,6 +2031,11 @@ export function createConversationUi(
if (workspace() !== next) workspace.set(next);
},
},
+ {
+ get loading() {
+ return input.surface !== 'overlays';
+ },
+ },
);
},
session(input: ConversationSessionInput) {
diff --git a/products/maka/terminal-shell/tests/real_host/ui_plugins.rs b/products/maka/terminal-shell/tests/real_host/ui_plugins.rs
index 50f0ec0695f..751d16c41c7 100644
--- a/products/maka/terminal-shell/tests/real_host/ui_plugins.rs
+++ b/products/maka/terminal-shell/tests/real_host/ui_plugins.rs
@@ -264,3 +264,313 @@ export function activate(context) {
host.shutdown().await.unwrap();
server.await.unwrap().unwrap();
}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn overlay_embedded_view_omits_loading_placeholder_without_clipping_error_recovery() {
+ use maka_app_client::Journal;
+ use maka_event_log::{
+ root::{RootNamespaces, RootOwner, private_directory},
+ root_store::RootStore,
+ };
+ use maka_host_runtime::{
+ application::{
+ NativeRegistry,
+ host::{Client as HostClient, Configuration, Host},
+ transport::{Grant, HostEndpoint},
+ },
+ ipc::LocalListener,
+ };
+ use maka_packages::Package;
+ use maka_plugin_core::composition::InstanceId;
+ use nsh_platform_tui::AppOptions;
+ use std::{collections::BTreeMap, sync::Arc};
+ use tokio_util::sync::CancellationToken;
+
+ let directory = tempfile::tempdir().unwrap();
+ private_directory(directory.path()).unwrap();
+ let owner = Arc::new(
+ RootOwner::create(
+ &directory.path().join("host"),
+ &RootNamespaces {
+ ownership: directory.path().join("owners"),
+ control: directory.path().join("control"),
+ },
+ )
+ .unwrap(),
+ );
+ let store = Arc::new(RootStore::open(owner).await.unwrap());
+ let manager = support::product_package("host-deployment-manager").await;
+ let receipts = support::product_package("receipts").await;
+ let gateway = support::product_package("deployment-gateway").await;
+ let root = Package::new(BTreeMap::from([
+ (
+ "maka.extension.json".into(),
+ serde_json::to_vec(&json!({
+ "schemaVersion": 1,
+ "id": "test.nsh.overlay-host-root",
+ "application": {
+ "ui": {
+ "module": "ui.js",
+ "configuration": { "type": "object", "properties": {}, "additionalProperties": false },
+ "imports": {},
+ "contributions": { "root": { "kind": "root" } }
+ }
+ }
+ }))
+ .unwrap(),
+ ),
+ (
+ "ui.js".into(),
+ br#"
+import { Text, View, signal } from 'nsh';
+import { Button } from 'nsh/base';
+export function activate(context) {
+ const opened = signal(false);
+ const suspended = signal(false);
+ globalThis.__markOverlayLoading = () => suspended.set(true);
+ return {
+ exports: {
+ root: {
+ kind: 'root',
+ render() {
+ return View({
+ style: { flex: 1 },
+ get children() {
+ return [
+ View({
+ style: { flex: 1 },
+ get children() {
+ return [
+ Text({
+ get children() {
+ return suspended()
+ ? 'MAIN_WORKBENCH_READY OVERLAY_LOADING_SUSPENDED'
+ : 'MAIN_WORKBENCH_READY WAITING_FOR_OVERLAY_IMPORT';
+ },
+ }),
+ Button({
+ accessibilityLabel: 'Release overlay load',
+ style: { height: 32, flexShrink: 0 },
+ onPress: () => globalThis.__releaseOverlay?.(),
+ children: Text({ children: 'Release overlay load' }),
+ }),
+ Button({
+ accessibilityLabel: 'Open overlay',
+ style: { height: 32, flexShrink: 0 },
+ onPress: () => opened.set(true),
+ children: Text({ children: 'Open overlay' }),
+ }),
+ Button({
+ accessibilityLabel: 'Bottom anchor',
+ style: { marginTop: 'auto', height: 32, flexShrink: 0 },
+ onPress: () => {},
+ children: Text({ children: 'Bottom anchor' }),
+ }),
+ ];
+ },
+ }),
+ context.runtime.renderView(
+ { path: ['overlay'], id: 'dialogHost' },
+ { id: 'test.overlay' },
+ { opened },
+ { loading: false },
+ ),
+ ];
+ },
+ });
+ },
+ },
+ },
+ };
+}
+"#
+ .to_vec(),
+ ),
+ ]))
+ .unwrap();
+ let overlay = Package::new(BTreeMap::from([
+ (
+ "maka.extension.json".into(),
+ serde_json::to_vec(&json!({
+ "schemaVersion": 1,
+ "id": "test.nsh.overlay-provider",
+ "application": {
+ "ui": {
+ "module": "overlay.js",
+ "configuration": { "type": "object", "properties": {}, "additionalProperties": false },
+ "imports": {},
+ "contributions": {
+ "dialogHost": { "kind": "view", "contract": "test.overlay" }
+ }
+ }
+ }
+ }))
+ .unwrap(),
+ ),
+ (
+ "overlay.js".into(),
+ br#"
+import { Text } from 'nsh';
+globalThis.__markOverlayLoading?.();
+await new Promise((resolve) => {
+ globalThis.__releaseOverlay = resolve;
+});
+let attempts = 0;
+export function activate() {
+ attempts += 1;
+ if (attempts === 1) {
+ throw new Error('OVERLAY_ACTIVATION_FAILED');
+ }
+ return {
+ exports: {
+ dialogHost: {
+ kind: 'view',
+ contract: 'test.overlay',
+ render(props) {
+ return () => (props.input.opened() ? Text({ children: 'OVERLAY_OPENED' }) : null);
+ },
+ },
+ },
+ };
+}
+"#
+ .to_vec(),
+ ),
+ ]))
+ .unwrap();
+ let node = |package: &Package, key: u128, use_package: &str, bindings: Value| {
+ json!({
+ "key": Uuid::from_u128(key),
+ "package": { "source": "pinned", "package": package.application_ref() },
+ "usePackage": use_package,
+ "configuration": { "values": {} },
+ "bindings": bindings
+ })
+ };
+ let deployment = Uuid::new_v4();
+ store
+ .commit_application(
+ deployment,
+ 0,
+ serde_json::from_value(json!({
+ "ui": { "path": ["ui"], "id": "root" },
+ "instances": {
+ "management": node(&manager, 1, "host", json!({})),
+ "receipts": node(&receipts, 2, "host", json!({})),
+ "gateway": node(&gateway, 3, "host", json!({
+ "management": [{ "kind": "instance", "path": ["management"], "export": "Management" }],
+ "receipts": [{ "kind": "instance", "path": ["receipts"], "export": "Receipts" }]
+ })),
+ "ui": node(&root, 4, "ui", json!({})),
+ "overlay": node(&overlay, 5, "ui", json!({}))
+ }
+ }))
+ .unwrap(),
+ vec![],
+ vec![manager, receipts, gateway, root, overlay],
+ )
+ .await
+ .unwrap();
+ let endpoint = |import: &str, methods: &[&str], recovery| HostEndpoint {
+ consumer: InstanceId(vec![Uuid::from_u128(3)]),
+ import: import.into(),
+ grant: Grant {
+ methods: methods.iter().map(|name| (*name).into()).collect(),
+ recovery,
+ },
+ };
+ let host = Host::open(
+ store,
+ deployment,
+ NativeRegistry::default(),
+ None,
+ Configuration::PublicHost {
+ endpoints: BTreeMap::from([
+ (
+ "management".into(),
+ endpoint("management", &["readUi", "watch", "exportPackage"], false),
+ ),
+ ("receipts".into(), endpoint("receipts", &["read"], true)),
+ ]),
+ native: vec![],
+ product_methods: Default::default(),
+ },
+ )
+ .await
+ .unwrap();
+ let address = support::socket(directory.path(), "overlay-host");
+ let listener = LocalListener::bind(&address).unwrap();
+ let server = tokio::spawn({
+ let host = host.clone();
+ async move { host.serve_local(listener, CancellationToken::new()).await }
+ });
+ let parent = HostClient::connect(&address, None).await.unwrap();
+ let client = Client::new(
+ parent,
+ Journal::open(&directory.path().join("client.sqlite"))
+ .await
+ .unwrap(),
+ "receipts".into(),
+ )
+ .unwrap();
+ let bindings =
+ maka_workbench::bindings::Bindings::new(client, tokio::runtime::Handle::current());
+ let mut view = AppView::new(AppOptions {
+ size: (100, 24),
+ data_dir: Some(directory.path().join("nsh")),
+ ..Default::default()
+ });
+ support::load_application(&mut view, &bindings, None);
+ let loading_screen = wait_view(&mut view, |screen| {
+ screen.contains("MAIN_WORKBENCH_READY OVERLAY_LOADING_SUSPENDED")
+ && screen.contains("Bottom anchor")
+ })
+ .await;
+ assert!(
+ !loading_screen.contains("Loading"),
+ "loading=false must suppress the loading placeholder:\n{loading_screen}"
+ );
+ let viewport_bottom = view.surface().viewport().height.0;
+ let anchor = view
+ .accessibility_snapshot()
+ .into_iter()
+ .find(|node| node.label == "Bottom anchor")
+ .expect("Bottom anchor in accessibility tree");
+ assert_eq!(
+ anchor.frame.origin.y.0 + anchor.frame.size.height.0,
+ viewport_bottom,
+ "main pane must keep the full viewport height while overlay view is loading: {:?}",
+ anchor.frame
+ );
+
+ // Release the overlay load: first activation fails, and both the error text
+ // and Retry button must remain unclipped and interactive.
+ support::click_button(&mut view, "Release overlay load").await;
+ wait_view(&mut view, |screen| {
+ screen.contains("This action could not be completed.") && screen.contains("Retry")
+ })
+ .await;
+ support::click_button(&mut view, "Retry").await;
+ wait_view(&mut view, |screen| {
+ !screen.contains("This action could not be completed.") && !screen.contains("Retry")
+ })
+ .await;
+ let anchor_after_retry = view
+ .accessibility_snapshot()
+ .into_iter()
+ .find(|node| node.label == "Bottom anchor")
+ .expect("Bottom anchor after retry");
+ assert_eq!(
+ anchor_after_retry.frame.origin.y.0 + anchor_after_retry.frame.size.height.0,
+ viewport_bottom,
+ "recovered closed overlay view must take zero height: {:?}",
+ anchor_after_retry.frame
+ );
+ support::click_button(&mut view, "Open overlay").await;
+ wait_view(&mut view, |screen| screen.contains("OVERLAY_OPENED")).await;
+
+ drop(view);
+ bindings.close().await.unwrap();
+ host.shutdown().await.unwrap();
+ server.await.unwrap().unwrap();
+}
diff --git a/products/maka/workbench/app/App.tsx b/products/maka/workbench/app/App.tsx
index 642ffb0f07f..8a713b6e0ca 100644
--- a/products/maka/workbench/app/App.tsx
+++ b/products/maka/workbench/app/App.tsx
@@ -342,29 +342,39 @@ function App(
- {runtime.renderView(configuration.preview, conversationPreviewView, {
- shell,
- surface: 'overlays' as const,
- docked: false,
- attach(handle) {
- if (preview() !== handle) preview.set(handle);
- },
- })}
- {runtime.renderView(configuration.settings, settingsViewContract, {
- opened: settingsOpened,
- close: () => {
- if (!alive || blocked()) return;
- navigationGeneration++;
- settingsOpened.set(false);
- },
- changed: () => notice.set(null),
- get model() {
- return settingsModel();
+ {runtime.renderView(
+ configuration.preview,
+ conversationPreviewView,
+ {
+ shell,
+ surface: 'overlays' as const,
+ docked: false,
+ attach(handle) {
+ if (preview() !== handle) preview.set(handle);
+ },
},
- get page() {
- return settingsPage();
+ { loading: false },
+ )}
+ {runtime.renderView(
+ configuration.settings,
+ settingsViewContract,
+ {
+ opened: settingsOpened,
+ close: () => {
+ if (!alive || blocked()) return;
+ navigationGeneration++;
+ settingsOpened.set(false);
+ },
+ changed: () => notice.set(null),
+ get model() {
+ return settingsModel();
+ },
+ get page() {
+ return settingsPage();
+ },
},
- })}
+ { loading: false },
+ )}
);
}
diff --git a/products/maka/workbench/app/plugins/RuntimeView.tsx b/products/maka/workbench/app/plugins/RuntimeView.tsx
index c99228c4dce..92521779ff3 100644
--- a/products/maka/workbench/app/plugins/RuntimeView.tsx
+++ b/products/maka/workbench/app/plugins/RuntimeView.tsx
@@ -30,6 +30,7 @@ import type { Contribution, Plugins } from './registry';
import type {
UiContributionRef,
UiContributionProps,
+ UiViewRenderOptions,
} from '../../../../../packages/plugin-sdk/src/workbench.js';
import type { UiTarget } from '../../../../../packages/plugin-sdk/src/generated/runtime.js';
@@ -80,6 +81,7 @@ type ViewProps = {
plugins: Plugins;
contribution: Contribution;
input: UiContributionProps;
+ options?: UiViewRenderOptions;
};
function PluginError(props: { reason: unknown; retry(): void }) {
@@ -122,9 +124,10 @@ function RuntimeView(props: ViewProps) {
@@ -212,12 +215,14 @@ export function renderEmbeddedView(
target: UiTarget,
contract: string,
input: unknown,
+ options?: UiViewRenderOptions,
) {
return (
plugins.resolveView(target, { id: contract })}
input={{ sessionId: null, input }}
+ options={options}
/>
);
}
@@ -226,6 +231,7 @@ function ContributionView(props: {
plugins: Plugins;
contribution(): Contribution | undefined;
input: UiContributionProps;
+ options?: UiViewRenderOptions;
}) {
return (
}>
@@ -237,7 +243,12 @@ function ContributionView(props: {
key={(contribution) => contribution.key}
>
{(contribution) => (
-
+
)}
diff --git a/products/maka/workbench/app/plugins/registry.ts b/products/maka/workbench/app/plugins/registry.ts
index e6481038078..571dd8c59fa 100644
--- a/products/maka/workbench/app/plugins/registry.ts
+++ b/products/maka/workbench/app/plugins/registry.ts
@@ -528,8 +528,8 @@ export function createPlugins() {
settings,
renderer: (presentation) => (presentation == null ? undefined : renderers().get(presentation)),
view,
- renderView(target, contract, input) {
- return renderEmbeddedView(manager, target, contract.id, input);
+ renderView(target, contract, input, options) {
+ return renderEmbeddedView(manager, target, contract.id, input, options);
},
render(reference: UiContributionRef, props: UiContributionProps) {
return renderContribution(manager, reference, props);
diff --git a/skills/maka-plugin-authoring/references/ui-plugins.md b/skills/maka-plugin-authoring/references/ui-plugins.md
index cad7488e3ed..4cb3ba992ef 100644
--- a/skills/maka-plugin-authoring/references/ui-plugins.md
+++ b/skills/maka-plugin-authoring/references/ui-plugins.md
@@ -102,7 +102,7 @@ const target = { path: ['counter-ui'], id: 'counter' };
return context.runtime.renderView(target, counterView, { label: 'Items' });
```
-`runtime.view(target, contract)` resolves a contribution without rendering it. Both methods reject a missing target, the wrong contribution kind or a different contract. `UiTarget` uses the same `{ path, id }` shape as root selection; keep replaceable targets in the consuming package's configuration. The contract name is checked at runtime, while TypeScript checks the input shape. Inputs can carry reactive accessors and callbacks between trusted UI modules; they are not Host wire messages.
+`runtime.view(target, contract)` resolves a contribution without rendering it. Both methods reject a missing target, the wrong contribution kind or a different contract. Pass `{ loading: false }` as the fourth argument to `runtime.renderView` when mounting a dialog-only view so its loading placeholder takes no layout height while keeping activation errors visible. `UiTarget` uses the same `{ path, id }` shape as root selection; keep replaceable targets in the consuming package's configuration. The contract name is checked at runtime, while TypeScript checks the input shape. Inputs can carry reactive accessors and callbacks between trusted UI modules; they are not Host wire messages.
The SDK's `workbench-views.ts`, `composer-view.ts` and `workspace-view.ts` define the current workbench contracts. Maka composes conversation navigation, sessions, previews, settings and the composer through ordinary contributions. `assets/composer-ui/` supplies a standalone replacement using the composer contract and resident session controller. The contract also exposes typed completion and checklist sources. The sample needs no Host imports or bindings. Its README explains building it and selecting its target.