diff --git a/apps/desktop/src/main/__tests__/runtime-host-ui-extensions-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-ui-extensions-ipc-main.test.ts
new file mode 100644
index 0000000000..b8426992c5
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/runtime-host-ui-extensions-ipc-main.test.ts
@@ -0,0 +1,97 @@
+import assert from 'node:assert/strict';
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { test } from 'node:test';
+import type { IpcHandler } from '../ipc-reconnect-policy.js';
+import type { DesktopRuntimeHostClient } from '../runtime-host-client.js';
+import { registerRuntimeHostUiExtensionsIpc } from '../runtime-host-ui-extensions-ipc-main.js';
+
+test('user import previews, confirms, installs, and enables one trusted UI and Event package', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-ui-import-'));
+ try {
+ await mkdir(join(root, 'documents'));
+ await mkdir(join(root, 'host'));
+ await mkdir(join(root, 'dist'));
+ await writeFile(
+ join(root, 'maka.extension.json'),
+ JSON.stringify({
+ schemaVersion: 1,
+ id: 'dev.maka.user.ui',
+ runtime: {
+ entry: 'dist/index.mjs',
+ tools: [],
+ events: [
+ {
+ name: 'dev.maka.user.ui.changed',
+ description: 'UI changed.',
+ payloadSchema: { type: 'object' },
+ },
+ ],
+ listeners: [
+ { id: 'changed', event: 'dev.maka.user.ui.changed', handler: 'changed' },
+ ],
+ services: [],
+ timers: [],
+ permissions: { workspace: 'none', network: false },
+ },
+ ui: {
+ contributions: [
+ { id: 'root', surface: 'app.root', priority: 1, document: 'documents/root.html' },
+ ],
+ host: { entry: 'host/service.mjs', methods: [{ name: 'hello', handler: 'hello' }] },
+ permissions: { network: false, hostState: true, sessionAccess: true },
+ },
+ }),
+ );
+ await writeFile(join(root, 'documents', 'root.html'), 'hello');
+ await writeFile(join(root, 'host', 'service.mjs'), 'export default { hello: () => "world" };');
+ await writeFile(
+ join(root, 'dist', 'index.mjs'),
+ 'export default { changed: () => undefined };',
+ );
+ const handlers = new Map();
+ const requests: Array<{ operation: string; input: unknown }> = [];
+ const client = {
+ request: async (operation: string, input: unknown) => {
+ requests.push({ operation, input });
+ if (operation === 'extension.package.install') {
+ return { extensionId: 'dev.maka.user.ui', toolNames: [], uiContributionIds: ['root'], eventContributionIds: ['event:dev.maka.user.ui.changed', 'listener:dev.maka.user.ui.changed:changed'] };
+ }
+ if (operation === 'extension.composition.query') return { extensions: [], entries: [] };
+ if (operation === 'extension.composition.mutate') return { entry: null };
+ throw new Error(`unexpected ${operation}`);
+ },
+ } as unknown as DesktopRuntimeHostClient;
+ registerRuntimeHostUiExtensionsIpc({
+ ipcMain: {
+ handle: (channel, listener) => handlers.set(channel, listener),
+ handleReconnectableRead: (channel, listener) => handlers.set(channel, listener),
+ },
+ client,
+ mainWindowController: {
+ showOpenDialog: async () => ({ canceled: false, filePaths: [root] }),
+ showMessageBox: async () => ({ response: 0, checkboxChecked: false }),
+ } as never,
+ allowLocalPaths: true,
+ });
+ const handler = handlers.get('ui-extensions:importLocal');
+ assert.ok(handler);
+ assert.deepEqual(await handler({} as never), { ok: true, extensionId: 'dev.maka.user.ui' });
+ assert.equal(requests[0]?.operation, 'extension.package.install');
+ const mutations = requests.filter(({ operation }) => operation === 'extension.composition.mutate');
+ assert.equal(mutations.length, 2);
+ assert.deepEqual(mutations[0], {
+ operation: 'extension.composition.mutate',
+ input: {
+ kind: 'enable',
+ entryId: mutations[0] && (mutations[0].input as { entryId: string }).entryId,
+ scopeId: 'desktop-ui',
+ extensionId: 'dev.maka.user.ui',
+ },
+ });
+ assert.equal((mutations[1]?.input as { scopeId?: string } | undefined)?.scopeId, 'profile');
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
diff --git a/apps/desktop/src/main/__tests__/ui-extension-host.test.ts b/apps/desktop/src/main/__tests__/ui-extension-host.test.ts
new file mode 100644
index 0000000000..2ecf53b368
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/ui-extension-host.test.ts
@@ -0,0 +1,194 @@
+import assert from 'node:assert/strict';
+import { afterEach, describe, test } from 'node:test';
+import { parseHTML } from 'linkedom';
+import { act, createElement, useEffect } from 'react';
+import { createRoot } from 'react-dom/client';
+import type { ExtensionUiContributionProjection } from '@maka/runtime-host/protocol';
+import { selectUiSnapshots, UiExtensionSlot, UiExtensionSlotProvider } from '../../renderer/ui-extension-host.js';
+import { withUiSandboxPolicy } from '../ui-extension-frame-document.js';
+import { createUiExtensionFrameRequestHandler } from '../ui-extension-frame-protocol.js';
+import { uiExtensionFrameUrl } from '../../renderer/ui-extension-frame-url.js';
+
+const originalGlobals = {
+ document: globalThis.document,
+ window: globalThis.window,
+ HTMLElement: globalThis.HTMLElement,
+ HTMLIFrameElement: globalThis.HTMLIFrameElement,
+ IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
+ .IS_REACT_ACT_ENVIRONMENT,
+};
+
+afterEach(() => {
+ Object.assign(globalThis, originalGlobals);
+});
+
+describe('Desktop UI extension shell', () => {
+ test('selects one deterministic root and ordered independent overlays', () => {
+ const selected = selectUiSnapshots(null, [
+ item('low', 'app.root', 1),
+ item('overlay-b', 'app.overlay', 20),
+ item('high', 'app.root', 100),
+ item('overlay-a', 'app.overlay', 20),
+ item('settings', 'app.slot', 30, 'settings.content'),
+ item('conversation', 'app.slot', 40, 'conversation.header'),
+ ]);
+ assert.equal(selected.root.id, 'high');
+ assert.deepEqual(selected.overlays.map(({ id }) => id), ['overlay-a', 'overlay-b']);
+ assert.deepEqual(selected.slots.map(({ id }) => id), ['conversation', 'settings']);
+ });
+
+ test('updates one slot without remounting the official root', async () => {
+ const { document, window } = parseHTML('');
+ Object.assign(globalThis, {
+ document,
+ window,
+ HTMLElement: window.HTMLElement,
+ HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {},
+ IS_REACT_ACT_ENVIRONMENT: true,
+ });
+ const container = document.querySelector('#root');
+ assert.ok(container);
+ const root = createRoot(container);
+ let mounts = 0;
+ let unmounts = 0;
+ function OfficialRootProbe() {
+ useEffect(() => {
+ mounts += 1;
+ return () => {
+ unmounts += 1;
+ };
+ }, []);
+ return createElement('main', { 'data-official-root': true });
+ }
+ const render = async (generation: number) => {
+ await act(async () => {
+ root.render(
+ createElement(
+ UiExtensionSlotProvider,
+ {
+ contributions: [
+ { ...item('status', 'app.slot', 10, 'conversation.header'), generation },
+ ],
+ onSafeMode: () => undefined,
+ },
+ createElement(OfficialRootProbe),
+ createElement(UiExtensionSlot, { name: 'conversation.header' }),
+ ),
+ );
+ await Promise.resolve();
+ });
+ };
+
+ await render(1);
+ await render(2);
+ assert.equal(mounts, 1);
+ assert.equal(unmounts, 0);
+ assert.equal(container.querySelectorAll('iframe').length, 1);
+ await act(async () => root.unmount());
+ assert.equal(unmounts, 1);
+ });
+
+ test('injects an offline CSP by default and only opens declared network lanes', () => {
+ const offline = withUiSandboxPolicy('Hello', false);
+ assert.match(offline, /connect-src 'none'/);
+ assert.match(offline, /frame-src 'none'/);
+ assert.ok(offline.indexOf('Content-Security-Policy') < offline.indexOf(''));
+ const online = withUiSandboxPolicy('Hello', true);
+ assert.match(online, /connect-src https: wss:/);
+ assert.match(online, /form-action 'none'/);
+ });
+
+ test('injects the narrow Host SDK only for an admitted frame token', () => {
+ const plain = withUiSandboxPolicy('Hello', false);
+ assert.doesNotMatch(plain, /makaUI/);
+ const bridged = withUiSandboxPolicy('Hello', false, 'test-token', [
+ 'workspace.body',
+ ]);
+ assert.match(bridged, /maka-ui-bridge\/v1/);
+ assert.match(bridged, /maka-ui-bridge-ready\/v1/);
+ assert.match(bridged, /maka-ui-host-ready\/v1/);
+ assert.match(bridged, /queued\.push/);
+ assert.match(bridged, /setInterval\(announce,50\)/);
+ assert.match(bridged, /clearInterval\(retry\)/);
+ assert.match(bridged, /getState/);
+ assert.match(bridged, /setState/);
+ assert.match(bridged, /deleteState/);
+ assert.match(bridged, /invoke/);
+ assert.match(bridged, /session_list/);
+ assert.match(bridged, /session_send/);
+ assert.match(bridged, /session_stop/);
+ assert.match(bridged, /safe_mode/);
+ assert.match(bridged, /maka-ui-slot-layout\/v1/);
+ assert.match(bridged, /data-maka-slot/);
+ assert.match(bridged, /workspace\.body/);
+ assert.match(bridged, /getConfig/);
+ assert.match(bridged, /test-token/);
+ });
+
+ test('serves active UI bytes from an isolated scheme instead of srcdoc CSP inheritance', async () => {
+ const token = '12345678-1234-4123-8123-123456789abc';
+ const contribution = item('root', 'app.root', 1);
+ const url = uiExtensionFrameUrl({
+ scopeId: 'desktop-ui',
+ entryId: contribution.entryId,
+ extensionId: contribution.extensionId,
+ generation: contribution.generation,
+ contributionId: contribution.id,
+ token,
+ });
+ const handler = createUiExtensionFrameRequestHandler(() => ({
+ request: async () => ({
+ scopeId: 'desktop-ui',
+ digest: 'sha256-test',
+ contributions: [{ ...contribution, hostState: true }],
+ }),
+ }));
+ const response = await handler(new Request(url));
+ assert.equal(response.status, 200);
+ assert.match(response.headers.get('content-security-policy') ?? '', /script-src 'unsafe-inline'/);
+ assert.match(await response.text(), /makaUI/);
+ });
+
+ test('injects the emergency recovery bridge even without extension permissions', async () => {
+ const token = '12345678-1234-4123-8123-123456789abc';
+ const contribution = item('root', 'app.root', 1);
+ const handler = createUiExtensionFrameRequestHandler(() => ({
+ request: async () => ({
+ scopeId: 'desktop-ui',
+ digest: 'sha256-test',
+ contributions: [contribution],
+ }),
+ }));
+ const response = await handler(new Request(uiExtensionFrameUrl({
+ scopeId: 'desktop-ui',
+ entryId: contribution.entryId,
+ extensionId: contribution.extensionId,
+ generation: contribution.generation,
+ contributionId: contribution.id,
+ token,
+ })));
+ const document = await response.text();
+ assert.match(document, /safe_mode/);
+ assert.match(document, /makaUI/);
+ });
+});
+
+function item(
+ id: string,
+ surface: 'app.root' | 'app.overlay' | 'app.slot',
+ priority: number,
+ slot?: string,
+): ExtensionUiContributionProjection {
+ return {
+ entryId: `entry-${id}`,
+ extensionId: 'demo',
+ generation: 1,
+ id,
+ surface,
+ ...(slot ? { slot } : {}),
+ priority,
+ document: 'demo
',
+ documentSha256: 'sha256',
+ network: false,
+ };
+}
diff --git a/apps/desktop/src/main/__tests__/ui-plugin-runtime.test.ts b/apps/desktop/src/main/__tests__/ui-plugin-runtime.test.ts
new file mode 100644
index 0000000000..7bb00e7c21
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/ui-plugin-runtime.test.ts
@@ -0,0 +1,41 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import type { ExtensionUiContributionProjection } from '@maka/runtime-host/protocol';
+import { UiPluginRuntime } from '../../renderer/ui-plugin-runtime.js';
+
+test('Client Cordis tree updates one UI entry without remounting siblings', async () => {
+ const runtime = new UiPluginRuntime();
+ const first = contribution('first', 1, 'one');
+ const sibling = contribution('sibling', 1, 'sibling');
+ await runtime.reconcile([first, sibling]);
+ assert.deepEqual(runtime.inspect().map(({ entryId, generation }) => [entryId, generation]), [
+ ['first', 1],
+ ['sibling', 1],
+ ]);
+ await runtime.reconcile([contribution('first', 2, 'two'), sibling]);
+ assert.deepEqual(runtime.inspect().map(({ entryId, generation }) => [entryId, generation]), [
+ ['first', 2],
+ ['sibling', 1],
+ ]);
+ await runtime.reconcile([sibling]);
+ assert.deepEqual(runtime.inspect().map(({ entryId }) => entryId), ['sibling']);
+ await runtime.close();
+});
+
+function contribution(
+ entryId: string,
+ generation: number,
+ id: string,
+): ExtensionUiContributionProjection {
+ return Object.freeze({
+ entryId,
+ extensionId: `fixture.${entryId}`,
+ generation,
+ id,
+ surface: 'app.overlay',
+ priority: 0,
+ document: '',
+ documentSha256: `${id}-${generation}`,
+ network: false,
+ });
+}
diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts
index dd217d9471..884a528463 100644
--- a/apps/desktop/src/main/main-window.ts
+++ b/apps/desktop/src/main/main-window.ts
@@ -32,6 +32,7 @@ export interface MainWindowController {
setTitleBarOverlayTheme(sender: Electron.WebContents, theme: unknown): void;
showOpenDialog(options: Electron.OpenDialogOptions): Promise;
showSaveDialog(options: Electron.SaveDialogOptions): Promise;
+ showMessageBox(options: Electron.MessageBoxOptions): Promise;
getBrowserViews(): BrowserViewManager;
disposeBrowserViews(): Promise;
hasOpenWindows(): boolean;
@@ -501,6 +502,11 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main
? dialog.showSaveDialog(mainWindow, options)
: dialog.showSaveDialog(options);
},
+ showMessageBox(options) {
+ return mainWindow
+ ? dialog.showMessageBox(mainWindow, options)
+ : dialog.showMessageBox(options);
+ },
getBrowserViews,
disposeBrowserViews,
hasOpenWindows() {
diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts
index f9a13e5b23..e6772e437e 100644
--- a/apps/desktop/src/main/main.ts
+++ b/apps/desktop/src/main/main.ts
@@ -1,9 +1,22 @@
-import { app, dialog } from 'electron';
+import { app, dialog, protocol } from 'electron';
import { installMainProcessLogCapture } from './main-process-diagnostics.js';
import { isIsolatedE2e } from './startup-context.js';
installMainProcessLogCapture();
+protocol.registerSchemesAsPrivileged([
+ {
+ scheme: 'maka-ui',
+ privileges: {
+ standard: true,
+ secure: true,
+ supportFetchAPI: false,
+ corsEnabled: false,
+ bypassCSP: false,
+ },
+ },
+]);
+
// The macOS app menu title and app.getName() consumers read this name. Set it
// before ready, unchanged from its historical pre-ready position.
//
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index cf058b5278..ad3b38375a 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -126,6 +126,8 @@ import {
updateRuntimeHostSettings,
} from "./runtime-host-settings-ipc-main.js";
import { registerRuntimeHostSkillsIpc } from "./runtime-host-skills-ipc-main.js";
+import { registerRuntimeHostUiExtensionsIpc } from "./runtime-host-ui-extensions-ipc-main.js";
+import { registerUiExtensionFrameProtocol } from "./ui-extension-frame-protocol-main.js";
import { registerRuntimeHostUsageIpc } from "./runtime-host-usage-ipc-main.js";
import { registerRuntimeHostWorkspaceIpc } from "./runtime-host-workspace-ipc-main.js";
import { resolveShellEnv } from "./shell-env.js";
@@ -937,6 +939,16 @@ function registerHostClientIpc(
openPath: (path) => shell.openPath(path),
allowLocalPaths: target.kind === "local",
});
+ registerRuntimeHostUiExtensionsIpc({
+ ipcMain: scopedIpc,
+ client,
+ mainWindowController,
+ allowLocalPaths: target.kind === "local",
+ automatedImportSourcePath: isIsolatedE2e
+ ? process.env.MAKA_E2E_UI_EXTENSION_PATH
+ : undefined,
+ });
+ registerUiExtensionFrameProtocol(client);
registerRuntimeHostSearchIpc({ ipcMain: scopedIpc, client });
registerRuntimeHostUsageIpc({
ipcMain: scopedIpc,
diff --git a/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts b/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts
index 9bb25af465..b5301b376b 100644
--- a/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts
@@ -77,11 +77,21 @@ function request(
return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value));
case 'execution.inspect.query':
return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value));
+ case 'extension.ui.snapshot':
+ return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value));
+ case 'extension.ui.state.query':
+ return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value));
+ case 'extension.configuration.query':
+ return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value));
case 'scheduled-task.mutate':
return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value));
case 'scheduled-task.query':
return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value));
case 'web-search.execute':
return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value));
+ case 'extension.ui.state.mutate':
+ return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value));
+ case 'extension.ui.rpc.invoke':
+ return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value));
}
}
diff --git a/apps/desktop/src/main/runtime-host-ui-extensions-ipc-main.ts b/apps/desktop/src/main/runtime-host-ui-extensions-ipc-main.ts
new file mode 100644
index 0000000000..060ecaf838
--- /dev/null
+++ b/apps/desktop/src/main/runtime-host-ui-extensions-ipc-main.ts
@@ -0,0 +1,240 @@
+import { createHash } from 'node:crypto';
+import { readFile, stat } from 'node:fs/promises';
+import { basename, join } from 'node:path';
+import type { ReconnectableReadIpcMain } from './ipc-reconnect-policy.js';
+import { handleReconnectableRead } from './ipc-reconnect-policy.js';
+import type { DesktopRuntimeHostClient } from './runtime-host-client.js';
+import type { createMainWindowController } from './main-window.js';
+
+const DESKTOP_UI_SCOPE = 'desktop-ui';
+const PROFILE_EXTENSION_SCOPE = 'profile';
+type MainWindowController = ReturnType;
+
+export function registerRuntimeHostUiExtensionsIpc(input: {
+ readonly ipcMain: ReconnectableReadIpcMain;
+ readonly client: DesktopRuntimeHostClient;
+ readonly mainWindowController: MainWindowController;
+ readonly allowLocalPaths: boolean;
+ readonly automatedImportSourcePath?: string;
+}): void {
+ handleReconnectableRead(input.ipcMain, 'ui-extensions:list', async () => listUiExtensions(input.client));
+
+ input.ipcMain.handle('ui-extensions:importLocal', async () => {
+ if (!input.allowLocalPaths) throw new Error('Local UI Extension import is unavailable for a remote Runtime Host');
+ const selected = input.automatedImportSourcePath
+ ? { canceled: false, filePaths: [input.automatedImportSourcePath] }
+ : await input.mainWindowController.showOpenDialog({
+ title: 'Import Extension',
+ properties: ['openDirectory', 'openFile'],
+ filters: [{ name: 'Maka Extension', extensions: ['maka-extension'] }],
+ });
+ const sourcePath = selected.filePaths[0];
+ if (selected.canceled || !sourcePath) return { ok: false as const, reason: 'cancelled' as const };
+ const manifest = await previewPackage(sourcePath);
+ const confirmation = input.automatedImportSourcePath
+ ? { response: 0 }
+ : await input.mainWindowController.showMessageBox({
+ type: 'warning',
+ title: `Import ${manifest.id}`,
+ message: `Install Extension “${manifest.id}”?`,
+ detail: [
+ `${manifest.uiCount} UI contribution${manifest.uiCount === 1 ? '' : 's'}`,
+ `${manifest.toolCount} Tool contribution${manifest.toolCount === 1 ? '' : 's'}`,
+ `${manifest.eventCount} Event/Listener contribution${manifest.eventCount === 1 ? '' : 's'}`,
+ `${manifest.serviceCount} Service contribution${manifest.serviceCount === 1 ? '' : 's'}`,
+ `${manifest.timerCount} Timer contribution${manifest.timerCount === 1 ? '' : 's'}`,
+ `Host state: ${manifest.permissions.hostState ? 'allowed' : 'not allowed'}`,
+ `Session control: ${manifest.permissions.sessionAccess ? 'allowed' : 'not allowed'}`,
+ `Host methods: ${manifest.hostMethods.length === 0 ? 'none' : manifest.hostMethods.join(', ')}`,
+ `Network: ${manifest.permissions.network ? 'allowed' : 'blocked'}`,
+ `Workspace: ${manifest.permissions.workspace}`,
+ '',
+ 'Trusted code warning: enabling this Extension executes its code inside the Runtime Host process. It has the same authority as a local application or Bash command and may read credentials, access files and the network, change MAKA behavior, block, or terminate the Runtime. Manifest permissions are approval and audit metadata, not a security boundary against malicious code.',
+ ].join('\n'),
+ buttons: ['Install and enable', 'Cancel'],
+ defaultId: 0,
+ cancelId: 1,
+ });
+ if (confirmation.response !== 0) return { ok: false as const, reason: 'cancelled' as const };
+ const installed = await input.client.request('extension.package.install', { sourcePath });
+ const catalog = await input.client.request('extension.composition.query', {});
+ for (const scopeId of new Set([
+ ...(installed.uiContributionIds.length > 0 ? [DESKTOP_UI_SCOPE] : []),
+ ...(installed.toolNames.length > 0 || installed.eventContributionIds.length > 0 || (installed.serviceContributionIds?.length ?? 0) > 0 || (installed.timerContributionIds?.length ?? 0) > 0
+ ? [PROFILE_EXTENSION_SCOPE]
+ : []),
+ ])) {
+ const current = catalog.entries.find(
+ (entry) => entry.scopeId === scopeId && entry.extensionId === installed.extensionId,
+ );
+ await input.client.request(
+ 'extension.composition.mutate',
+ current
+ ? { kind: 'reload', entryId: current.entryId }
+ : {
+ kind: 'enable',
+ entryId: userEntryId(installed.extensionId, scopeId),
+ scopeId,
+ extensionId: installed.extensionId,
+ },
+ );
+ }
+ return { ok: true as const, extensionId: installed.extensionId };
+ });
+
+ input.ipcMain.handle('ui-extensions:setEnabled', async (_event, extensionId: string, enabled: boolean) => {
+ const catalog = await input.client.request('extension.composition.query', {});
+ const entries = catalog.entries.filter((item) => item.extensionId === extensionId);
+ if (entries.length === 0) throw new Error('Extension entry is not installed');
+ for (const entry of entries) {
+ await input.client.request('extension.composition.mutate', enabled
+ ? { kind: 'enable', entryId: entry.entryId, scopeId: entry.scopeId, extensionId: entry.extensionId }
+ : { kind: 'disable', entryId: entry.entryId });
+ }
+ return { ok: true as const };
+ });
+
+ input.ipcMain.handle('ui-extensions:remove', async (_event, extensionId: string) => {
+ const catalog = await input.client.request('extension.composition.query', {});
+ for (const entry of catalog.entries.filter((item) => item.extensionId === extensionId)) {
+ await input.client.request('extension.composition.mutate', { kind: 'remove', entryId: entry.entryId });
+ }
+ if (catalog.extensions.some((item) => item.extensionId === extensionId))
+ await input.client.request('extension.package.uninstall', { extensionId });
+ return { ok: true as const };
+ });
+
+ input.ipcMain.handle('ui-extensions:configure', async (_event, entryId: string, configuration: Record) => {
+ const result = await input.client.request('extension.configuration.mutate', { entryId, configuration });
+ return { ok: true as const, configuration: result.configuration };
+ });
+
+ handleReconnectableRead(input.ipcMain, 'ui-extensions:getConfiguration', async (_event, entryId: string) =>
+ input.client.request('extension.configuration.query', { entryId }),
+ );
+
+ input.ipcMain.handle('ui-extensions:export', async (_event, extensionId: string) => {
+ if (!input.allowLocalPaths) throw new Error('Extension export is unavailable for a remote Runtime Host');
+ const selected = await input.mainWindowController.showSaveDialog({
+ title: `Export ${extensionId}`,
+ defaultPath: `${extensionId}.maka-extension`,
+ filters: [{ name: 'Maka Extension', extensions: ['maka-extension'] }],
+ });
+ if (selected.canceled || !selected.filePath) return { ok: false as const, reason: 'cancelled' as const };
+ await input.client.request('extension.package.export', { extensionId, targetPath: selected.filePath });
+ return { ok: true as const, path: selected.filePath };
+ });
+}
+
+async function listUiExtensions(client: DesktopRuntimeHostClient) {
+ const catalog = await client.request('extension.composition.query', {});
+ const contracts = await client.request('extension.contract.query', {}).catch(() => ({ packages: [] }));
+ return catalog.extensions
+ .map((extension) => {
+ const entries = catalog.entries.filter((item) => item.extensionId === extension.extensionId);
+ const contract = contracts.packages.find((item) => item.extensionId === extension.extensionId);
+ return {
+ extensionId: extension.extensionId,
+ displayName: contract?.displayName ?? extension.extensionId,
+ description: contract?.description ?? '',
+ contributionIds: [
+ ...extension.toolNames,
+ ...extension.uiContributionIds,
+ ...extension.eventContributionIds,
+ ...(extension.serviceContributionIds ?? []),
+ ...(extension.timerContributionIds ?? []),
+ ],
+ toolNames: extension.toolNames,
+ uiContributionIds: extension.uiContributionIds,
+ eventContributionIds: extension.eventContributionIds,
+ serviceContributionIds: extension.serviceContributionIds ?? [],
+ timerContributionIds: extension.timerContributionIds ?? [],
+ dependencies: contract?.dependencies ?? [],
+ configuration: contract?.configuration ?? { properties: {}, required: [] },
+ entries,
+ active: entries.some((item) => item.status === 'active'),
+ enabled: entries.some((item) => item.enabled),
+ status: entries.some((item) => item.status === 'failed') ? 'failed' : entries.some((item) => item.status === 'active') ? 'active' : entries.some((item) => item.status === 'waiting') ? 'waiting' : 'disabled',
+ error: entries.find((item) => item.error)?.error ?? null,
+ };
+ });
+}
+
+async function previewPackage(sourcePath: string): Promise<{ id: string; uiCount: number; toolCount: number; eventCount: number; serviceCount: number; timerCount: number; hostMethods: string[]; permissions: { network: boolean; hostState: boolean; sessionAccess: boolean; workspace: string } }> {
+ if (!(await stat(sourcePath)).isDirectory()) return previewBundle(sourcePath);
+ const encoded = await readFile(join(sourcePath, 'maka.extension.json'), 'utf8');
+ return previewManifest(JSON.parse(encoded) as Record);
+}
+
+async function previewBundle(sourcePath: string): ReturnType {
+ const encoded = await readFile(sourcePath);
+ if (encoded.byteLength > 32 * 1024 * 1024) throw new Error('Extension Bundle is too large');
+ const bundle = JSON.parse(encoded.toString('utf8')) as { files?: unknown };
+ if (!Array.isArray(bundle.files)) throw new Error('Extension Bundle is invalid');
+ let manifest: string | undefined;
+ for (const value of bundle.files) {
+ const file = value as { path?: unknown; content?: unknown };
+ if (typeof file.path !== 'string' || typeof file.content !== 'string') {
+ throw new Error('Extension Bundle file is invalid');
+ }
+ if (file.path === 'maka.extension.json') {
+ manifest = Buffer.from(file.content, 'base64').toString('utf8');
+ }
+ }
+ if (!manifest) {
+ throw new Error(`Extension Bundle is missing manifests: ${basename(sourcePath)}`);
+ }
+ return previewManifest(JSON.parse(manifest) as Record);
+}
+
+function previewManifest(value: Record): Awaited> {
+ if (typeof value.id !== 'string') {
+ throw new Error('Extension manifest is invalid');
+ }
+ const runtime = value.runtime as Record | undefined;
+ const uiValue = value.ui as Record | undefined;
+ const ui = Array.isArray(uiValue?.contributions) ? uiValue.contributions : [];
+ const tools = Array.isArray(runtime?.tools) ? runtime.tools : [];
+ const eventDefinitions = Array.isArray(runtime?.events) ? runtime.events : [];
+ const listeners = Array.isArray(runtime?.listeners) ? runtime.listeners : [];
+ const services = Array.isArray(runtime?.services) ? runtime.services : [];
+ const timers = Array.isArray(runtime?.timers) ? runtime.timers : [];
+ if (
+ ui.length === 0 &&
+ tools.length === 0 &&
+ eventDefinitions.length === 0 &&
+ listeners.length === 0 &&
+ services.length === 0 &&
+ timers.length === 0
+ ) {
+ throw new Error('Extension package has no contributions');
+ }
+ const permissions = uiValue?.permissions as Record | undefined;
+ const runtimePermissions = runtime?.permissions as Record | undefined;
+ const host = uiValue?.host as Record | undefined;
+ const methods = Array.isArray(host?.methods) ? host.methods : [];
+ const hostMethods = methods.map((item) => (item as Record)?.name);
+ if (hostMethods.some((name) => typeof name !== 'string')) {
+ throw new Error('Extension Bundle Host methods are invalid');
+ }
+ return {
+ id: value.id,
+ uiCount: ui.length,
+ toolCount: tools.length,
+ eventCount: eventDefinitions.length + listeners.length,
+ serviceCount: services.length,
+ timerCount: timers.length,
+ hostMethods: hostMethods as string[],
+ permissions: {
+ network: permissions?.network === true || runtimePermissions?.network === true,
+ hostState: permissions?.hostState === true,
+ sessionAccess: permissions?.sessionAccess === true,
+ workspace:
+ typeof runtimePermissions?.workspace === 'string' ? runtimePermissions.workspace : 'none',
+ },
+ };
+}
+
+function userEntryId(extensionId: string, scopeId: string): string {
+ return `user_extension_${createHash('sha256').update(`${scopeId}\u0000${extensionId}`).digest('hex').slice(0, 32)}`;
+}
diff --git a/apps/desktop/src/main/ui-extension-frame-document.ts b/apps/desktop/src/main/ui-extension-frame-document.ts
new file mode 100644
index 0000000000..2055fa04a3
--- /dev/null
+++ b/apps/desktop/src/main/ui-extension-frame-document.ts
@@ -0,0 +1,25 @@
+export function uiExtensionFramePolicy(network: boolean): string {
+ const networkPolicy = network
+ ? "connect-src https: wss:; img-src data: blob: https:; media-src blob: https:; font-src data: https:;"
+ : "connect-src 'none'; img-src data: blob:; media-src blob:; font-src data:;";
+ return `default-src 'none'; base-uri 'none'; object-src 'none'; frame-src 'none'; form-action 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; ${networkPolicy}`;
+}
+
+export function withUiSandboxPolicy(
+ document: string,
+ network: boolean,
+ bridgeToken?: string,
+ declaredSlots: readonly string[] = [],
+): string {
+ const policy = ``;
+ const bridge = bridgeToken
+ ? ``
+ : '';
+ const head = /^\s*(?:]*>\s*)?]*)?>\s*]*)?>/iu;
+ if (head.test(document)) return document.replace(head, (match) => `${match}${policy}${bridge}`);
+ return `${policy}${bridge}${document}`;
+}
+
+function bridgeBootstrap(token: string, declaredSlots: readonly string[]): string {
+ return `(function(){const token=${JSON.stringify(token)},declaredSlots=${JSON.stringify(declaredSlots)},pending=new Map(),queued=[];let sequence=0,ready=false,layoutFrame=0;const announce=function(){if(!ready)parent.postMessage({channel:'maka-ui-bridge-ready/v1',token:token},'*');},retry=setInterval(announce,50);function publishSlots(){layoutFrame=0;const slots=[];for(const name of declaredSlots){const element=document.querySelector('[data-maka-slot="'+CSS.escape(name)+'"]');if(!element)continue;const rect=element.getBoundingClientRect();slots.push({name:name,x:rect.x,y:rect.y,width:rect.width,height:rect.height});}parent.postMessage({channel:'maka-ui-slot-layout/v1',token:token,slots:slots},'*');}function scheduleSlots(){if(!layoutFrame)layoutFrame=requestAnimationFrame(publishSlots);}window.addEventListener('resize',scheduleSlots);window.addEventListener('scroll',scheduleSlots,true);new MutationObserver(scheduleSlots).observe(document.documentElement,{attributes:true,childList:true,subtree:true});if(typeof ResizeObserver==='function')new ResizeObserver(scheduleSlots).observe(document.documentElement);window.addEventListener('keydown',function(event){if((event.metaKey||event.ctrlKey)&&event.shiftKey&&event.key==='Backspace'){event.preventDefault();event.stopImmediatePropagation();const id=String(++sequence),envelope={channel:'maka-ui-bridge/v1',token:token,id:id,kind:'safe_mode'};ready?parent.postMessage(envelope,'*'):queued.push(envelope);}},true);window.addEventListener('message',function(event){const data=event.data;if(event.source!==parent||!data||data.token!==token)return;if(data.channel==='maka-ui-host-ready/v1'){if(ready)return;ready=true;clearInterval(retry);while(queued.length)parent.postMessage(queued.shift(),'*');scheduleSlots();return;}if(data.channel!=='maka-ui-host/v1')return;const task=pending.get(data.id);if(!task)return;pending.delete(data.id);data.ok?task.resolve(data.result):task.reject(new Error(data.error||'Host request failed'));});function call(message){return new Promise(function(resolve,reject){const id=String(++sequence),envelope=Object.assign({channel:'maka-ui-bridge/v1',token,id},message);pending.set(id,{resolve,reject});ready?parent.postMessage(envelope,'*'):queued.push(envelope);});}const sessions=Object.freeze({list:function(){return call({kind:'session_list'});},send:function(input){input=input||{};return call({kind:'session_send',sessionId:input.sessionId,text:input.text});},stop:function(sessionId){return call({kind:'session_stop',sessionId:sessionId});}});Object.defineProperty(window,'makaUI',{value:Object.freeze({getConfig:function(){return call({kind:'config'}).then(function(result){return result.configuration;});},getState:function(key){return call({kind:'get',key:key});},setState:function(key,value){return call({kind:'set',key:key,value:value});},deleteState:function(key){return call({kind:'delete',key:key});},invoke:function(method,args){return call({kind:'invoke',method:method,args:args===undefined?null:args});},sessions:sessions}),writable:false,configurable:false});setTimeout(announce,0);})();`;
+}
diff --git a/apps/desktop/src/main/ui-extension-frame-protocol-main.ts b/apps/desktop/src/main/ui-extension-frame-protocol-main.ts
new file mode 100644
index 0000000000..9172dc6332
--- /dev/null
+++ b/apps/desktop/src/main/ui-extension-frame-protocol-main.ts
@@ -0,0 +1,19 @@
+import { protocol } from 'electron';
+import type { DesktopRuntimeHostClient } from './runtime-host-client.js';
+import {
+ createUiExtensionFrameRequestHandler,
+ type UiExtensionFrameClient,
+} from './ui-extension-frame-protocol.js';
+
+let installed = false;
+let activeClient: UiExtensionFrameClient | null = null;
+
+export function registerUiExtensionFrameProtocol(client: DesktopRuntimeHostClient): void {
+ activeClient = client;
+ if (installed) return;
+ installed = true;
+ protocol.handle(
+ 'maka-ui',
+ createUiExtensionFrameRequestHandler(() => activeClient),
+ );
+}
diff --git a/apps/desktop/src/main/ui-extension-frame-protocol.ts b/apps/desktop/src/main/ui-extension-frame-protocol.ts
new file mode 100644
index 0000000000..5478982940
--- /dev/null
+++ b/apps/desktop/src/main/ui-extension-frame-protocol.ts
@@ -0,0 +1,110 @@
+import type { ExtensionUiSnapshotResult } from '@maka/runtime-host/protocol';
+import {
+ uiExtensionFramePolicy,
+ withUiSandboxPolicy,
+} from './ui-extension-frame-document.js';
+
+const TOKEN_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
+const EXPECTED_QUERY_KEYS = Object.freeze([
+ 'contributionId',
+ 'entryId',
+ 'extensionId',
+ 'generation',
+ 'scopeId',
+ 'token',
+]);
+
+export interface UiExtensionFrameClient {
+ request(
+ operation: 'extension.ui.snapshot',
+ input: { readonly scopeId: string },
+ ): Promise;
+}
+
+export function createUiExtensionFrameRequestHandler(
+ resolveClient: () => UiExtensionFrameClient | null,
+): (request: Request) => Promise {
+ return async (request) => {
+ if (request.method !== 'GET') return response('Method not allowed', 405);
+ const identity = decodeFrameIdentity(request.url);
+ if (!identity) return response('Invalid UI Extension frame request', 400);
+ const client = resolveClient();
+ if (!client) return response('Runtime Host unavailable', 503);
+ try {
+ const snapshot = await client.request('extension.ui.snapshot', {
+ scopeId: identity.scopeId,
+ });
+ const contribution = snapshot.contributions.find(
+ (item) =>
+ item.entryId === identity.entryId &&
+ item.extensionId === identity.extensionId &&
+ item.generation === identity.generation &&
+ item.id === identity.contributionId,
+ );
+ if (!contribution) return response('UI Extension contribution not active', 404);
+ const document = withUiSandboxPolicy(
+ contribution.document,
+ contribution.network,
+ identity.token,
+ contribution.slots ?? [],
+ );
+ return new Response(document, {
+ status: 200,
+ headers: {
+ 'cache-control': 'no-store',
+ 'content-security-policy': uiExtensionFramePolicy(contribution.network),
+ 'content-type': 'text/html; charset=utf-8',
+ 'cross-origin-resource-policy': 'cross-origin',
+ },
+ });
+ } catch {
+ return response('Runtime Host unavailable', 503);
+ }
+ };
+}
+
+function decodeFrameIdentity(urlValue: string): {
+ readonly scopeId: 'desktop-ui';
+ readonly entryId: string;
+ readonly extensionId: string;
+ readonly generation: number;
+ readonly contributionId: string;
+ readonly token: string;
+} | null {
+ const url = new URL(urlValue);
+ if (url.protocol !== 'maka-ui:' || url.hostname !== 'frame' || url.pathname !== '/v1') {
+ return null;
+ }
+ if ([...url.searchParams.keys()].sort().join('\0') !== EXPECTED_QUERY_KEYS.join('\0')) {
+ return null;
+ }
+ const scopeId = url.searchParams.get('scopeId');
+ const entryId = url.searchParams.get('entryId');
+ const extensionId = url.searchParams.get('extensionId');
+ const generation = Number(url.searchParams.get('generation'));
+ const contributionId = url.searchParams.get('contributionId');
+ const token = url.searchParams.get('token');
+ if (
+ scopeId !== 'desktop-ui' ||
+ !entryId ||
+ !extensionId ||
+ !Number.isSafeInteger(generation) ||
+ generation <= 0 ||
+ !contributionId ||
+ !token ||
+ !TOKEN_PATTERN.test(token)
+ ) {
+ return null;
+ }
+ return { scopeId, entryId, extensionId, generation, contributionId, token };
+}
+
+function response(body: string, status: number): Response {
+ return new Response(body, {
+ status,
+ headers: {
+ 'cache-control': 'no-store',
+ 'content-type': 'text/plain; charset=utf-8',
+ },
+ });
+}
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 56aec7f324..88d75ea5e1 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -305,6 +305,40 @@ export interface PetPackChangedEvent {
readonly ts: number;
}
+export interface UiExtensionEntry {
+ readonly extensionId: string;
+ readonly displayName: string;
+ readonly description: string;
+ readonly contributionIds: readonly string[];
+ readonly toolNames: readonly string[];
+ readonly uiContributionIds: readonly string[];
+ readonly eventContributionIds: readonly string[];
+ readonly serviceContributionIds: readonly string[];
+ readonly timerContributionIds: readonly string[];
+ readonly dependencies: readonly { readonly id: string }[];
+ readonly configuration: {
+ readonly properties: Readonly>;
+ readonly required: readonly string[];
+ };
+ readonly entries: readonly {
+ readonly entryId: string;
+ readonly scopeId: string;
+ readonly enabled: boolean;
+ readonly status: 'disabled' | 'active' | 'waiting' | 'failed';
+ }[];
+ readonly active: boolean;
+ readonly enabled: boolean;
+ readonly status: 'disabled' | 'active' | 'waiting' | 'failed';
+ readonly error: string | null;
+}
+
export interface MakaBridge {
runtimeHost: {
query(
@@ -317,6 +351,16 @@ export interface MakaBridge {
): Promise>;
};
+ uiExtensions: {
+ list(): Promise;
+ importLocal(): Promise<{ ok: true; extensionId: string } | { ok: false; reason: 'cancelled' }>;
+ setEnabled(extensionId: string, enabled: boolean): Promise<{ ok: true }>;
+ getConfiguration(entryId: string): Promise<{ configuration: Record }>;
+ configure(entryId: string, configuration: Record): Promise<{ ok: true; configuration: Record }>;
+ export(extensionId: string): Promise<{ ok: true; path: string } | { ok: false; reason: 'cancelled' }>;
+ remove(extensionId: string): Promise<{ ok: true }>;
+ };
+
runtimeHostProfiles: {
getSnapshot(): Promise;
addAndSelect(
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 7c789e09a8..2521ae1f0f 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -496,6 +496,29 @@ async function bridgeResult(operation: () => Promise, code: string): Promi
const makaBridge = {
runtimeHost,
+ uiExtensions: {
+ list() {
+ return invokeActiveRuntimeHost('ui-extensions:list');
+ },
+ importLocal() {
+ return invokeActiveRuntimeHost('ui-extensions:importLocal');
+ },
+ setEnabled(extensionId: string, enabled: boolean) {
+ return invokeActiveRuntimeHost('ui-extensions:setEnabled', extensionId, enabled);
+ },
+ getConfiguration(entryId: string) {
+ return invokeActiveRuntimeHost('ui-extensions:getConfiguration', entryId);
+ },
+ configure(entryId: string, configuration: Record) {
+ return invokeActiveRuntimeHost('ui-extensions:configure', entryId, configuration);
+ },
+ export(extensionId: string) {
+ return invokeActiveRuntimeHost('ui-extensions:export', extensionId);
+ },
+ remove(extensionId: string) {
+ return invokeActiveRuntimeHost('ui-extensions:remove', extensionId);
+ },
+ },
runtimeHostProfiles: {
getSnapshot() {
return ipcRenderer.invoke('runtime-host-profiles:getSnapshot');
diff --git a/apps/desktop/src/preload/runtime-host-renderer-operations.ts b/apps/desktop/src/preload/runtime-host-renderer-operations.ts
index e0dde01165..0e65bbc3cd 100644
--- a/apps/desktop/src/preload/runtime-host-renderer-operations.ts
+++ b/apps/desktop/src/preload/runtime-host-renderer-operations.ts
@@ -7,6 +7,9 @@ export const RENDERER_RUNTIME_HOST_QUERY_OPERATIONS = [
'context.diagnostics.query',
'daily-review.query',
'execution.inspect.query',
+ 'extension.ui.snapshot',
+ 'extension.ui.state.query',
+ 'extension.configuration.query',
'scheduled-task.query',
] as const satisfies readonly (keyof OperationSpecMap)[];
@@ -14,6 +17,8 @@ export const RENDERER_RUNTIME_HOST_COMMAND_OPERATIONS = [
'daily-review.mutate',
'scheduled-task.mutate',
'web-search.execute',
+ 'extension.ui.state.mutate',
+ 'extension.ui.rpc.invoke',
] as const satisfies readonly (keyof OperationSpecMap)[];
/** Runtime Host operations that the sandboxed renderer may invoke directly. */
diff --git a/apps/desktop/src/renderer/app-shell-detail-panel.tsx b/apps/desktop/src/renderer/app-shell-detail-panel.tsx
index 1778f6f961..c0ba43a315 100644
--- a/apps/desktop/src/renderer/app-shell-detail-panel.tsx
+++ b/apps/desktop/src/renderer/app-shell-detail-panel.tsx
@@ -4,7 +4,7 @@ type AppShellDetailPanelProps = Omit<
ComponentPropsWithoutRef<'div'>,
'className' | 'data-agents-view'
> & {
- agentsView: 'skills' | 'mcp' | 'cron' | 'daily-review' | 'im_hub';
+ agentsView: 'skills' | 'mcp' | 'ui' | 'cron' | 'daily-review' | 'im_hub';
};
export function AppShellDetailPanel({
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index 10d4637f8d..617c4d4407 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -7,6 +7,7 @@ import {
useState,
type CSSProperties,
type Dispatch,
+ type ReactNode,
type SetStateAction,
} from 'react';
import type { ScheduledTask } from '@maka/core/scheduled-task';
@@ -93,6 +94,8 @@ import {
usePlanModeState,
} from './plan-mode-panel';
import { McpPage } from './mcp-page';
+import { UiExtensionsPage } from './ui-extensions-page';
+import { UiExtensionSlot } from './ui-extension-host';
import { getOnboardingActivationCandidate, useOnboardingSnapshot } from './use-onboarding-snapshot';
import type { AppUpdateStatus, OnboardingSnapshot } from '../preload/bridge-contract.js';
import { DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES } from '../preload/transcript-contract.js';
@@ -232,7 +235,9 @@ type AppShellProps = {
initialOnboardingSnapshot?: OnboardingSnapshot | null;
};
-export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = {}) {
+export function AppShell({
+ initialOnboardingSnapshot = null,
+}: AppShellProps = {}) {
const [uiLocalePreference, setUiLocalePreference] = useState('auto');
const [uiLocaleOverride, setUiLocaleOverride] = useState(null);
const systemUiLocale = useSystemUiLocale();
@@ -2780,6 +2785,7 @@ function AppShellContent({
onImport={() => setExternalImportOpen(true)}
rowActions={sessionRowActions}
projectActions={projectRowActions}
+ footerExtension={}
/>
}
>
@@ -2825,6 +2831,8 @@ function AppShellContent({
/>
) : navSelection.section === 'extensions' && navSelection.module === 'mcp' ? (
+ ) : navSelection.section === 'extensions' && navSelection.module === 'ui' ? (
+
) : navSelection.section === 'automations' && navSelection.module === 'scheduled-tasks' ? (
{navSelection.section === 'sessions' ? (
}
sessionUiController={sessionUiController}
activeSessionId={activeId}
hasOlderHistory={activeTranscriptRange?.hasOlder === true}
diff --git a/apps/desktop/src/renderer/app.tsx b/apps/desktop/src/renderer/app.tsx
index 75d5e20573..af7f8725e8 100644
--- a/apps/desktop/src/renderer/app.tsx
+++ b/apps/desktop/src/renderer/app.tsx
@@ -7,6 +7,7 @@ import { useAstryxThemeMode } from './astryx-theme-mode';
import type { OnboardingSnapshot } from '../preload/bridge-contract.js';
import { RuntimeHostSshTerminalDialog } from './settings/runtime-host-ssh-terminal-dialog.js';
import { readSystemUiLocale } from './use-system-ui-locale';
+import { UiExtensionHost } from './ui-extension-host';
export function App({
initialOnboardingSnapshot = null,
@@ -63,7 +64,11 @@ export function App({
{runtimeHostReady ? (
-
+ (
+
+ )}
+ />
) : (
diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx
index 5719dd55f9..feda212f6e 100644
--- a/apps/desktop/src/renderer/chat-message-surface.tsx
+++ b/apps/desktop/src/renderer/chat-message-surface.tsx
@@ -74,6 +74,8 @@ interface ChatMessageSurfaceProps extends Omit<
historyLoadPending: boolean;
onLoadEarlierHistory: () => Promise | void;
onReturnToLatestHistory: () => Promise | void;
+ /** Independently lifecycle-managed UI contributions above the transcript. */
+ headerExtension?: ReactNode;
}
function captureLiveContent(
@@ -112,6 +114,7 @@ export function ChatMessageSurface({
historyLoadPending,
onLoadEarlierHistory,
onReturnToLatestHistory,
+ headerExtension,
...chatViewRest
}: ChatMessageSurfaceProps) {
const locale = useUiLocale();
@@ -214,6 +217,7 @@ export function ChatMessageSurface({
return (
<>
+ {headerExtension}
Maka