Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/plugin-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Input>(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<Input>(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.

Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-sdk/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Input>(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<Input>(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(...)` 执行命令。

Expand Down
11 changes: 10 additions & 1 deletion packages/plugin-sdk/src/workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ export interface UiContributionProps {
readonly tool?: Omit<UiToolProps, 'context'>;
}

export interface UiViewRenderOptions {
readonly loading?: boolean;
}

/** The shared catalog and components; the selected root owns their presentation. */
export interface UiRuntime<Element = unknown> {
source(): DeploymentRef | null;
Expand All @@ -171,7 +175,12 @@ export interface UiRuntime<Element = unknown> {
settings(): readonly UiContributionRef[];
renderer(presentation: string | null | undefined): UiContributionRef | undefined;
view<Input>(target: UiTarget, contract: UiViewContract<Input>): UiContributionRef;
renderView<Input>(target: UiTarget, contract: UiViewContract<Input>, input: Input): Element;
renderView<Input>(
target: UiTarget,
contract: UiViewContract<Input>,
input: Input,
options?: UiViewRenderOptions,
): Element;
render(contribution: UiContributionRef, props: UiContributionProps): Element;
openPanel(target: UiTarget, input?: unknown): void;
commandEnabled(contribution: UiContributionRef, context: UiCommandContext): boolean;
Expand Down
9 changes: 9 additions & 0 deletions packages/plugin-sdk/tests/application.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,10 +536,19 @@ function renderSessionView(runtime: UiRuntime<string>, 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];
5 changes: 5 additions & 0 deletions plugins/agent/conversations/ui/SessionSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2031,6 +2031,11 @@ export function createConversationUi(
if (workspace() !== next) workspace.set(next);
},
},
{
get loading() {
return input.surface !== 'overlays';
},
},
);
},
session(input: ConversationSessionInput) {
Expand Down
310 changes: 310 additions & 0 deletions products/maka/terminal-shell/tests/real_host/ui_plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Loading
Loading