mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:08:23 +08:00
feat: land the Canvas Extensions frontend (load pages, sidebar, customize)
This commit is contained in:
@@ -90,6 +90,7 @@ describe("ExtensionsNavigation", () => {
|
||||
"MCP Servers",
|
||||
"Skills",
|
||||
"Plugins",
|
||||
"Extensions",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -126,7 +127,7 @@ describe("ExtensionsNavigation", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("hides the Plugins item", () => {
|
||||
it("hides the Plugins and Extensions items", () => {
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
|
||||
@@ -136,6 +137,9 @@ describe("ExtensionsNavigation", () => {
|
||||
expect(
|
||||
within(nav).queryByTestId("sidebar-extensions-/plugins"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(nav).queryByTestId("sidebar-extensions-/extensions"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("leaves the MCP Servers item as an in-app link", () => {
|
||||
|
||||
@@ -29,6 +29,17 @@ describe("getMobileTopBarState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("backs from the canvas extensions management page but not extension pages", () => {
|
||||
expect(getMobileTopBarState("/extensions")).toEqual({
|
||||
mode: "back",
|
||||
backTo: "/customize",
|
||||
backLabelKey: I18nKey.NAV$CUSTOMIZE,
|
||||
});
|
||||
expect(getMobileTopBarState("/extensions/demo-page/hello")).toEqual({
|
||||
mode: "menu",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows menu on main app routes", () => {
|
||||
expect(getMobileTopBarState("/conversations")).toEqual({ mode: "menu" });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Canvas Extensions manual testing
|
||||
|
||||
Canvas Extensions can be exercised locally before the Agent Server implements
|
||||
the `/api/canvas-extensions` endpoints. Canvas's existing MSW development mode
|
||||
contains an in-memory implementation of the API and serves the checked-in demo
|
||||
extension bundle through the same frontend service and runtime used in a real
|
||||
deployment.
|
||||
|
||||
This path is for frontend development only. It does not test Agent Server
|
||||
installation, filesystem validation, persistence, authentication, or Git
|
||||
resolution.
|
||||
|
||||
## Start the mock frontend
|
||||
|
||||
From the repository root, run:
|
||||
|
||||
```sh
|
||||
VITE_FRONTEND_PORT=3102 \
|
||||
VITE_BACKEND_BASE_URL=http://127.0.0.1:8000 \
|
||||
VITE_SESSION_API_KEY=canvas-extension-dev \
|
||||
npm run dev:mock
|
||||
```
|
||||
|
||||
Port `3102` avoids the `3001` Vite process used by the normal local stack. The
|
||||
backend URL only gives Canvas a local backend identity; MSW intercepts the
|
||||
extension requests in the browser. The mock also covers the settings and server
|
||||
information probes needed to mark that backend healthy, so the Agent Server
|
||||
does not need the extension endpoints and does not need to be running.
|
||||
|
||||
Open <http://localhost:3102/extensions>. Do not use the normal ingress URL at
|
||||
`http://localhost:8000` for this test because its `/api` traffic goes directly
|
||||
to the unmodified Agent Server rather than through the mock browser session.
|
||||
|
||||
If the browser profile already contains incompatible backend or onboarding
|
||||
state, use a private window or clear local storage for `localhost:3102` and
|
||||
reload.
|
||||
|
||||
## Install and enable the fixture
|
||||
|
||||
1. In **Customize -> Extensions**, select **Add extension**.
|
||||
2. Enter this exact source:
|
||||
|
||||
```text
|
||||
tests/fixtures/canvas-extensions/demo-page
|
||||
```
|
||||
|
||||
3. Leave **Ref** and **Repository path** empty, then select **Install**.
|
||||
4. Confirm that **Demo page** appears disabled. Installation must not execute
|
||||
the bundle or add its navigation item.
|
||||
5. Turn on the extension and accept the trusted-code confirmation.
|
||||
6. Confirm that **Extension demo** appears in the main left rail.
|
||||
7. Open it and verify the page says **Hello from a Canvas Extension**.
|
||||
8. Visit `/extensions/demo-page/hello/nested` directly and verify the page
|
||||
renders `Nested extension path: nested`.
|
||||
|
||||
## Lifecycle checks
|
||||
|
||||
- **Disable:** turn the extension off. Its rail item should disappear, and its
|
||||
route should no longer render the contributed page.
|
||||
- **Re-enable:** turn it on again. The item and page should return without a
|
||||
Canvas restart.
|
||||
- **Uninstall:** select **Uninstall** and confirm. The inventory and rail item
|
||||
should become empty.
|
||||
- **Reload:** reload the page and confirm the installation and enablement are
|
||||
retained for this browser tab. The mock uses session storage and clears when
|
||||
you uninstall it or end the browser session.
|
||||
|
||||
## Test an extension edit
|
||||
|
||||
Edit
|
||||
`tests/fixtures/canvas-extensions/demo-page/extension.js`, restart the mock
|
||||
frontend if Vite does not rebuild the raw fixture import automatically, then
|
||||
uninstall and reinstall the fixture. This allows page mounting, cleanup,
|
||||
subrouting, and use of the host API to be developed before backend support is
|
||||
available.
|
||||
|
||||
The fixture must remain a self-contained browser ES module: it may not rely on
|
||||
bare package imports or additional output chunks.
|
||||
|
||||
## What still requires the Agent Server
|
||||
|
||||
Repeat this flow against `http://localhost:8000/extensions` after the backend
|
||||
contract lands. That test must additionally verify:
|
||||
|
||||
- Git and backend-local-path installation;
|
||||
- immutable revision resolution;
|
||||
- manifest, traversal, symlink, and entrypoint validation;
|
||||
- persistence across Agent Server and browser restarts;
|
||||
- session-authenticated bundle delivery;
|
||||
- isolation when switching between active backends.
|
||||
|
||||
The backend contract and acceptance criteria are documented in
|
||||
[`specs/canvas-extensions.md`](../specs/canvas-extensions.md).
|
||||
@@ -5,6 +5,7 @@ This directory contains the project documentation.
|
||||
- [Architecture](./architecture.md): system boundaries, runtime modes, and quality gates.
|
||||
- [Using ACP agents](./ACP_AGENTS.md): onboard and configure external agents (Claude Code, Codex, Gemini CLI).
|
||||
- [Development guide](./DEVELOPMENT.md)
|
||||
- [Canvas Extensions manual testing](./CANVAS_EXTENSIONS_TESTING.md)
|
||||
- [Self-hosting guide](./SELF_HOSTING.md)
|
||||
- [Integrating DefenseClaw](./DefenseClaw.md): run the DefenseClaw security governance layer alongside the Agent Server.
|
||||
- [Testing matrix](./TESTING_MATRIX.md): release smoke-test coverage across installers, operating systems, and agents.
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
# Canvas Extensions
|
||||
|
||||
## Status
|
||||
|
||||
Implementation plan and v1 contract. The frontend vertical slice may ship behind
|
||||
Agent Server capability detection while the backend endpoints are implemented.
|
||||
|
||||
## Product definition
|
||||
|
||||
Canvas Extensions are installable packages that change Agent Canvas itself.
|
||||
They contribute UI and local product behavior such as routed pages, conversation
|
||||
panels, renderers, slots, and themes. Skills and plugins change the agent;
|
||||
Canvas Extensions change the app.
|
||||
|
||||
The Customize area remains the single inventory for Skills, Plugins, MCP, and
|
||||
Canvas Extensions. The inventory item is named **Extensions**; "addon" is an
|
||||
informal alias only.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **The active Agent Server owns extensions.** An extension is installed on the
|
||||
computer or container running the Agent Server. Its source, resolved revision,
|
||||
files, manifest, and enabled state live there. Canvas only discovers and loads
|
||||
extensions from the currently active backend. Switching backends replaces the
|
||||
active extension set.
|
||||
2. **Extension code is trusted, same-realm code.** There is no iframe, worker
|
||||
sandbox, or granular permission system. Once enabled, an extension has the
|
||||
same ambient browser authority as Canvas code. Shadow DOM may be offered later
|
||||
as optional style isolation, but never as a security boundary.
|
||||
3. **Install and enable are separate.** Installation always produces a disabled
|
||||
extension. An agent may install or update an extension, but the user returns
|
||||
to Customize -> Extensions and explicitly enables it. In v1 this is a product
|
||||
consent invariant, not proof of human presence against an agent that can call
|
||||
the same authenticated APIs. A future backend policy may allow agent-driven
|
||||
enablement.
|
||||
4. **Enablement is hot.** Enabling loads and activates the extension without an
|
||||
app or Agent Server restart. Disabling unmounts registered surfaces and calls
|
||||
lifecycle cleanup. Because code is trusted same-realm JavaScript, cleanup is
|
||||
best-effort; an extension can create global effects the host cannot revoke.
|
||||
5. **Distribution follows plugins, not their runtime.** Install coordinates are
|
||||
`source`, optional `ref`, and optional `repo_path`, resolved and pinned by the
|
||||
Agent Server. A repository may contain multiple extensions and other artifact
|
||||
types under subpaths. Backend-local paths are interpreted on the backend
|
||||
machine, never in the frontend process.
|
||||
6. **Updates preserve enablement.** Refresh resolves and installs a new revision
|
||||
atomically and keeps the prior enabled state. The UI shows the resulting
|
||||
resolved revision. Since v1 is a trusted-code model, there is no misleading
|
||||
permission-diff approval gate. The staged check/apply flow currently exists
|
||||
only at the Agent Server service layer; until it is exposed over HTTP, the
|
||||
Customize UI offers no Refresh action.
|
||||
|
||||
## Trust disclosure
|
||||
|
||||
Before enabling, Canvas says plainly that the extension can access and modify the
|
||||
Canvas page and can make authenticated requests available to the current browser
|
||||
session. The review screen shows source, requested ref, resolved revision,
|
||||
manifest metadata, and contributed surfaces. It does not show fictitious
|
||||
fine-grained permissions.
|
||||
|
||||
Install and update are still meaningful trust actions because they select the
|
||||
code revision stored by the backend. Enable is the explicit point at which Canvas
|
||||
executes that code.
|
||||
|
||||
## Package contract
|
||||
|
||||
The manifest filename is `canvas-extension.json`.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "example-dashboard",
|
||||
"display_name": "Example dashboard",
|
||||
"version": "0.1.0",
|
||||
"description": "A backend-specific project dashboard.",
|
||||
"entrypoint": "dist/extension.js",
|
||||
"contributes": {
|
||||
"pages": [
|
||||
{
|
||||
"id": "dashboard",
|
||||
"title": "Dashboard",
|
||||
"path": "/dashboard",
|
||||
"nav_label": "Dashboard"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules for v1:
|
||||
|
||||
- `name` and contribution IDs use lowercase letters, digits, and hyphens.
|
||||
- Page `path` values are absolute kebab-case routes (leading `/`); Canvas
|
||||
mounts them relative to `/extensions/{name}`.
|
||||
- `entrypoint` is a path inside the installed package.
|
||||
- The entrypoint is one self-contained browser ESM bundle. It must not contain
|
||||
unresolved bare imports or external chunks; dependencies, CSS, and small
|
||||
assets are bundled or embedded by the authoring template.
|
||||
- The backend validates that the manifest, entrypoint, and any future asset path
|
||||
remain inside the installed extension root.
|
||||
- Host compatibility fields will be added before marketplace distribution. The
|
||||
initial schema is intentionally small while the page ABI is proven by a
|
||||
separately built sample extension.
|
||||
|
||||
The v1 module exports `activate`:
|
||||
|
||||
```ts
|
||||
export function activate(host: CanvasExtensionHost): void | (() => void) {
|
||||
return host.registerPage("dashboard", ({ container, path, navigate }) => {
|
||||
container.textContent = `Extension route: ${path}`;
|
||||
return () => container.replaceChildren();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
`CanvasExtensionHost` is versioned independently from the manifest. The first
|
||||
host API contains:
|
||||
|
||||
- `apiVersion: "1"`
|
||||
- immutable extension/backend metadata
|
||||
- `registerPage(id, mount)` for page factories declared by the manifest
|
||||
- `navigate(path)` using Canvas base-path-aware routing
|
||||
- `agentServer.request(...)`, an authenticated request helper targeting the
|
||||
extension's owning backend
|
||||
|
||||
The runtime fetches the bundle as authenticated text and imports it through a
|
||||
temporary Blob URL. A direct `<script src>` or `import(backendUrl)` cannot carry
|
||||
`X-Session-API-Key`, so it is not the v1 loading path. Importing the bundle does
|
||||
not activate it; Canvas calls `activate` only for an enabled installation.
|
||||
|
||||
## Agent Server API
|
||||
|
||||
The API deliberately mirrors plugin distribution management while remaining a
|
||||
separate runtime:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| -------- | ------------------------------------------------- | --------------------------------------------------------- |
|
||||
| `GET` | `/api/canvas-extensions/installed` | List installed extensions and parsed manifests |
|
||||
| `POST` | `/api/canvas-extensions/install` | Install from git or a backend-local path; always disabled |
|
||||
| `GET` | `/api/canvas-extensions/installed/{name}` | Read one installation |
|
||||
| `PATCH` | `/api/canvas-extensions/installed/{name}` | Set enabled state |
|
||||
| `DELETE` | `/api/canvas-extensions/installed/{name}` | Uninstall |
|
||||
| `GET` | `/api/canvas-extensions/installed/{name}/bundle` | Return the entrypoint as JavaScript text |
|
||||
|
||||
A refresh endpoint (`POST /api/canvas-extensions/installed/{name}/refresh`) is
|
||||
planned but not part of the current router; the service-layer staged check/apply
|
||||
flow backs it and is tracked separately.
|
||||
|
||||
The list endpoint wraps its results in a `canvas_extensions` array.
|
||||
|
||||
Install request:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "github:owner/repository",
|
||||
"ref": "main",
|
||||
"repo_path": "extensions/example-dashboard",
|
||||
"force": false
|
||||
}
|
||||
```
|
||||
|
||||
Installed response:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "example-dashboard",
|
||||
"version": "0.1.0",
|
||||
"description": "A backend-specific project dashboard.",
|
||||
"enabled": false,
|
||||
"source": "github:owner/repository",
|
||||
"resolved_ref": "6f5f9a...",
|
||||
"repo_path": "extensions/example-dashboard",
|
||||
"installed_at": "2026-08-01T12:00:00Z",
|
||||
"install_path": "/home/user/.openhands/canvas-extensions/installed/example-dashboard",
|
||||
"manifest": {}
|
||||
}
|
||||
```
|
||||
|
||||
The `manifest` field (parsed `canvas-extension.json`, including
|
||||
`contributes.pages`) is not returned by the current router yet; until it ships,
|
||||
Canvas treats it as optional and renders installations without page
|
||||
contributions or a display name.
|
||||
|
||||
The backend must prevent install requests from setting `enabled: true`. The
|
||||
frontend's enable control calls the patch endpoint. Backend authorization and
|
||||
multi-user installation scope follow the Agent Server deployment's existing
|
||||
session trust model; v1 does not introduce a separate approval credential.
|
||||
|
||||
Until the API exists, Canvas treats HTTP 404 as "this backend does not support
|
||||
Canvas Extensions". Customize shows an upgrade message, while the global runtime
|
||||
stays silent. It must not collapse unsupported, unreachable, and an empty
|
||||
inventory into the same state.
|
||||
|
||||
## Frontend runtime
|
||||
|
||||
The runtime is keyed by backend ID, organization scope, and connection revision.
|
||||
This prevents an enabled bundle or cached inventory from one backend appearing
|
||||
on another after a switch or reconnect.
|
||||
|
||||
For each enabled installation:
|
||||
|
||||
1. Fetch the authenticated entrypoint text from the owning backend.
|
||||
2. Import the self-contained ESM bundle from a Blob URL.
|
||||
3. Validate `activate` and its host API version.
|
||||
4. Create an extension-scoped registry and call `activate`.
|
||||
5. Admit registrations only when their IDs were declared in the manifest.
|
||||
6. Render each surface inside an error boundary owned by Canvas.
|
||||
7. On disable, uninstall, update, or backend switch, unmount every surface,
|
||||
invoke the extension disposer, and remove its registry entries.
|
||||
|
||||
The initial route shape is
|
||||
`/extensions/{extension-name}/{declared-page-path}`. `/extensions` itself is the
|
||||
Customize inventory. All routing goes through React Router so `VITE_BASE_PATH`
|
||||
continues to work.
|
||||
|
||||
## Delivery plan
|
||||
|
||||
### Slice 1: contract and management
|
||||
|
||||
- Land this spec and shared TypeScript manifest/installation types.
|
||||
- Add a backend-keyed service and query hooks for list, install, enable/disable,
|
||||
uninstall, and authenticated bundle fetch.
|
||||
- Add Customize -> Extensions with an unsupported-backend state, source install
|
||||
form, source/revision/contribution review, and explicit enable control.
|
||||
- Keep the global runtime silent when the active backend returns 404.
|
||||
|
||||
### Slice 2: routed page proof
|
||||
|
||||
- Add the versioned host API, module loader, lifecycle registry, and error
|
||||
boundary.
|
||||
- Add dynamic page routes and left-rail navigation for enabled page
|
||||
contributions.
|
||||
- Build a standalone fixture extension with the same scaffold authors will use.
|
||||
- Verify enable, hot activation, navigation, disable cleanup, update, backend
|
||||
switching, session-key-authenticated bundle loading, and `VITE_BASE_PATH`.
|
||||
|
||||
### Slice 3: theme proof
|
||||
|
||||
- Define a code-free theme contribution with a strict Canvas token schema.
|
||||
- Validate theme manifests on the backend and surface installed themes in
|
||||
Settings.
|
||||
- Keep theme selection browser/user scoped while availability follows the active
|
||||
backend; fall back cleanly when switching away from the providing backend.
|
||||
|
||||
### Slice 4: conversation surfaces
|
||||
|
||||
- Replace the closed conversation tab union with namespaced runtime IDs and
|
||||
centralized persistence admission.
|
||||
- Add extension tabs/panels, then host-owned header/footer/badge slots.
|
||||
- Define mobile behavior, ordering, conflicts, missing-extension fallback, and
|
||||
per-conversation lifecycle context before opening each surface to authors.
|
||||
|
||||
### Slice 5: visualizers and authoring loop
|
||||
|
||||
- Add augment/replace visualizer registrations with explicit event matching,
|
||||
precedence, and built-in fallback.
|
||||
- Publish the stable extension SDK types, bundler template, validation command,
|
||||
and sample repository.
|
||||
- Only then ship `/build-canvas-extension`, including backend-local versus remote
|
||||
workspace guidance, temporary install, manual enable instructions, and git
|
||||
publication/update flow.
|
||||
|
||||
## Verification
|
||||
|
||||
Each slice adds service and runtime unit tests. The routed page slice also adds a
|
||||
mock-LLM end-to-end fixture against a backend implementing the new endpoints.
|
||||
Changing the mock-LLM feature layout or mapping requires updating `AGENTS.md` in
|
||||
the same change. Before merge, run `npm run lint`, `npm test`, `npm run build`,
|
||||
and `npm run build:lib`.
|
||||
|
||||
## Explicit non-goals for v1
|
||||
|
||||
- iframe/worker isolation or enforceable fine-grained permissions
|
||||
- arbitrary lifecycle scripts during installation
|
||||
- automatic enablement by agents
|
||||
- marketplace ranking or publisher signing
|
||||
- conversation tabs, slots, or visualizer replacement in the first vertical
|
||||
slice
|
||||
- treating `@openhands/extensions` as an installable runtime format
|
||||
@@ -0,0 +1,173 @@
|
||||
import { AgentServerClient } from "@openhands/typescript-client/clients";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import CanvasExtensionsService, {
|
||||
CanvasExtensionsUnsupportedError,
|
||||
} from "#/api/canvas-extensions-service";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import type { InstalledCanvasExtensionInfo } from "#/types/canvas-extension";
|
||||
|
||||
vi.mock("@openhands/typescript-client/clients", () => ({
|
||||
AgentServerClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const get = vi.fn();
|
||||
const post = vi.fn();
|
||||
const patch = vi.fn();
|
||||
const remove = vi.fn();
|
||||
const request = vi.fn();
|
||||
|
||||
const localBackend: Backend = {
|
||||
id: "local",
|
||||
name: "Local",
|
||||
host: "http://127.0.0.1:8000",
|
||||
apiKey: "session-key",
|
||||
kind: "local",
|
||||
};
|
||||
|
||||
const extension: InstalledCanvasExtensionInfo = {
|
||||
name: "demo-extension",
|
||||
version: "0.1.0",
|
||||
description: "Demo",
|
||||
enabled: false,
|
||||
source: "github:example/extensions",
|
||||
resolved_ref: "abc123",
|
||||
repo_path: "extensions/demo",
|
||||
installed_at: "2026-08-01T00:00:00Z",
|
||||
install_path: "/tmp/extensions/demo-extension",
|
||||
manifest: {
|
||||
schema_version: 1,
|
||||
name: "demo-extension",
|
||||
version: "0.1.0",
|
||||
entrypoint: "dist/extension.js",
|
||||
contributes: {
|
||||
pages: [{ id: "home", title: "Home", path: "home" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([localBackend]);
|
||||
setActiveSelection({ backendId: localBackend.id });
|
||||
vi.mocked(AgentServerClient).mockImplementation(
|
||||
function MockAgentServerClient() {
|
||||
return {
|
||||
get,
|
||||
post,
|
||||
patch,
|
||||
delete: remove,
|
||||
request,
|
||||
close: vi.fn(),
|
||||
} as unknown as AgentServerClient;
|
||||
} as unknown as typeof AgentServerClient,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActiveSelection(null);
|
||||
setRegisteredBackends([]);
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("CanvasExtensionsService", () => {
|
||||
it("lists extensions installed on the active Agent Server", async () => {
|
||||
get.mockResolvedValue({ canvas_extensions: [extension] });
|
||||
|
||||
await expect(CanvasExtensionsService.listInstalled()).resolves.toEqual([
|
||||
extension,
|
||||
]);
|
||||
expect(get).toHaveBeenCalledWith("/api/canvas-extensions/installed");
|
||||
});
|
||||
|
||||
it("preserves install coordinates without allowing an enabled flag", async () => {
|
||||
post.mockResolvedValue(extension);
|
||||
|
||||
await CanvasExtensionsService.install({
|
||||
source: "github:example/extensions",
|
||||
ref: "main",
|
||||
repo_path: "extensions/demo",
|
||||
});
|
||||
|
||||
expect(post).toHaveBeenCalledWith("/api/canvas-extensions/install", {
|
||||
source: "github:example/extensions",
|
||||
ref: "main",
|
||||
repo_path: "extensions/demo",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a missing router to an explicit unsupported-backend error", async () => {
|
||||
get.mockRejectedValue(
|
||||
Object.assign(new Error("Not Found"), {
|
||||
name: "HttpError",
|
||||
status: 404,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(CanvasExtensionsService.listInstalled()).rejects.toEqual(
|
||||
expect.objectContaining<Partial<CanvasExtensionsUnsupportedError>>({
|
||||
name: "CanvasExtensionsUnsupportedError",
|
||||
reason: "missing-api",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call the Agent Server protocol for a Cloud backend", async () => {
|
||||
const cloud: Backend = {
|
||||
...localBackend,
|
||||
id: "cloud",
|
||||
name: "Cloud",
|
||||
kind: "cloud",
|
||||
};
|
||||
setRegisteredBackends([cloud]);
|
||||
setActiveSelection({ backendId: cloud.id });
|
||||
|
||||
await expect(CanvasExtensionsService.listInstalled()).rejects.toEqual(
|
||||
expect.objectContaining({ reason: "cloud-backend" }),
|
||||
);
|
||||
expect(AgentServerClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches the bundle as authenticated text from the captured backend", async () => {
|
||||
get.mockResolvedValue("export const activate = () => {};");
|
||||
|
||||
await expect(
|
||||
CanvasExtensionsService.fetchBundle(extension.name, localBackend),
|
||||
).resolves.toContain("activate");
|
||||
|
||||
expect(AgentServerClient).toHaveBeenCalledWith({
|
||||
host: localBackend.host,
|
||||
apiKey: localBackend.apiKey,
|
||||
timeout: 60000,
|
||||
});
|
||||
expect(get).toHaveBeenCalledWith(
|
||||
"/api/canvas-extensions/installed/demo-extension/bundle",
|
||||
{ responseType: "text" },
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes extension names in mutation paths", async () => {
|
||||
patch.mockResolvedValue({ name: "a/b", enabled: true });
|
||||
|
||||
await CanvasExtensionsService.setEnabled("a/b", true);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"/api/canvas-extensions/installed/a%2Fb",
|
||||
{ enabled: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not send the backend session key to an absolute request URL", async () => {
|
||||
await expect(
|
||||
CanvasExtensionsService.requestAgentServer({
|
||||
path: "https://example.com/collect",
|
||||
}),
|
||||
).rejects.toThrow("root-relative path");
|
||||
expect(AgentServerClient).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { AgentServerClient } from "@openhands/typescript-client/clients";
|
||||
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
|
||||
import { isSdkHttpStatusError } from "#/api/agent-server-compatibility";
|
||||
import {
|
||||
getActiveBackend,
|
||||
isNoBackend,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import type {
|
||||
CanvasExtensionAgentServerRequest,
|
||||
InstallCanvasExtensionRequest,
|
||||
InstalledCanvasExtensionInfo,
|
||||
} from "#/types/canvas-extension";
|
||||
|
||||
const CANVAS_EXTENSIONS_BASE_PATH = "/api/canvas-extensions";
|
||||
|
||||
export type CanvasExtensionsUnsupportedReason =
|
||||
| "no-backend"
|
||||
| "cloud-backend"
|
||||
| "missing-api";
|
||||
|
||||
export class CanvasExtensionsUnsupportedError extends Error {
|
||||
readonly reason: CanvasExtensionsUnsupportedReason;
|
||||
|
||||
constructor(reason: CanvasExtensionsUnsupportedReason) {
|
||||
const message =
|
||||
reason === "no-backend"
|
||||
? "Add an Agent Server backend to use Canvas Extensions."
|
||||
: reason === "cloud-backend"
|
||||
? "Canvas Extensions are not available on Cloud backends yet."
|
||||
: "This Agent Server does not support Canvas Extensions yet. Upgrade the backend and try again.";
|
||||
super(message);
|
||||
this.name = "CanvasExtensionsUnsupportedError";
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
export function isCanvasExtensionsUnsupportedError(
|
||||
error: unknown,
|
||||
): error is CanvasExtensionsUnsupportedError {
|
||||
return (
|
||||
error instanceof CanvasExtensionsUnsupportedError ||
|
||||
(typeof error === "object" &&
|
||||
error !== null &&
|
||||
"name" in error &&
|
||||
error.name === "CanvasExtensionsUnsupportedError")
|
||||
);
|
||||
}
|
||||
|
||||
function requireSupportedBackend(): void {
|
||||
const { backend } = getActiveBackend();
|
||||
if (isNoBackend(backend)) {
|
||||
throw new CanvasExtensionsUnsupportedError("no-backend");
|
||||
}
|
||||
if (backend.kind === "cloud") {
|
||||
throw new CanvasExtensionsUnsupportedError("cloud-backend");
|
||||
}
|
||||
}
|
||||
|
||||
function getClient(): AgentServerClient {
|
||||
requireSupportedBackend();
|
||||
return new AgentServerClient(getAgentServerClientOptions());
|
||||
}
|
||||
|
||||
function getClientForBackend(backend: Backend): AgentServerClient {
|
||||
if (isNoBackend(backend)) {
|
||||
throw new CanvasExtensionsUnsupportedError("no-backend");
|
||||
}
|
||||
if (backend.kind === "cloud") {
|
||||
throw new CanvasExtensionsUnsupportedError("cloud-backend");
|
||||
}
|
||||
return new AgentServerClient({
|
||||
host: backend.host,
|
||||
...(backend.apiKey ? { apiKey: backend.apiKey } : {}),
|
||||
timeout: 60000,
|
||||
});
|
||||
}
|
||||
|
||||
async function mapUnsupported<T>(operation: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (isSdkHttpStatusError(error, 404)) {
|
||||
throw new CanvasExtensionsUnsupportedError("missing-api");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function installedExtensionPath(name: string): string {
|
||||
return `${CANVAS_EXTENSIONS_BASE_PATH}/installed/${encodeURIComponent(name)}`;
|
||||
}
|
||||
|
||||
class CanvasExtensionsService {
|
||||
static async listInstalled(): Promise<InstalledCanvasExtensionInfo[]> {
|
||||
const client = getClient();
|
||||
const response = await mapUnsupported(() =>
|
||||
client.get<{ canvas_extensions: InstalledCanvasExtensionInfo[] }>(
|
||||
`${CANVAS_EXTENSIONS_BASE_PATH}/installed`,
|
||||
),
|
||||
);
|
||||
return response.canvas_extensions ?? [];
|
||||
}
|
||||
|
||||
static async install(
|
||||
request: InstallCanvasExtensionRequest,
|
||||
): Promise<InstalledCanvasExtensionInfo> {
|
||||
const client = getClient();
|
||||
return mapUnsupported(() =>
|
||||
client.post<InstalledCanvasExtensionInfo>(
|
||||
`${CANVAS_EXTENSIONS_BASE_PATH}/install`,
|
||||
request,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static async setEnabled(
|
||||
name: string,
|
||||
enabled: boolean,
|
||||
): Promise<{ name: string; enabled: boolean }> {
|
||||
const client = getClient();
|
||||
return mapUnsupported(() =>
|
||||
client.patch<{ name: string; enabled: boolean }>(
|
||||
installedExtensionPath(name),
|
||||
{ enabled },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static async uninstall(name: string): Promise<{ message: string }> {
|
||||
const client = getClient();
|
||||
return mapUnsupported(() =>
|
||||
client.delete<{ message: string }>(installedExtensionPath(name)),
|
||||
);
|
||||
}
|
||||
|
||||
static async fetchBundle(name: string, backend?: Backend): Promise<string> {
|
||||
const client = backend ? getClientForBackend(backend) : getClient();
|
||||
return mapUnsupported(() =>
|
||||
client.get<string>(`${installedExtensionPath(name)}/bundle`, {
|
||||
responseType: "text",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static async requestAgentServer<T = unknown>(
|
||||
request: CanvasExtensionAgentServerRequest,
|
||||
backend?: Backend,
|
||||
): Promise<T> {
|
||||
if (!request.path.startsWith("/") || request.path.startsWith("//")) {
|
||||
throw new Error(
|
||||
"Canvas Extension Agent Server requests must use a root-relative path.",
|
||||
);
|
||||
}
|
||||
const client = backend ? getClientForBackend(backend) : getClient();
|
||||
return client.request<T>({
|
||||
method: request.method ?? "GET",
|
||||
path: request.path,
|
||||
body: request.body,
|
||||
headers: request.headers,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default CanvasExtensionsService;
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalCloseButton } from "#/components/shared/modals/modal-close-button";
|
||||
import { useInstallCanvasExtension } from "#/hooks/mutation/use-manage-canvas-extensions";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { modalTitleLgClassName } from "#/utils/modal-classes";
|
||||
|
||||
interface AddCanvasExtensionModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AddCanvasExtensionModal({
|
||||
onClose,
|
||||
}: AddCanvasExtensionModalProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const install = useInstallCanvasExtension();
|
||||
const [source, setSource] = React.useState("");
|
||||
const [ref, setRef] = React.useState("");
|
||||
const [repoPath, setRepoPath] = React.useState("");
|
||||
|
||||
const trimmedSource = source.trim();
|
||||
const canSubmit = trimmedSource.length > 0 && !install.isPending;
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
install.mutate(
|
||||
{
|
||||
source: trimmedSource,
|
||||
ref: ref.trim() || null,
|
||||
repo_path: repoPath.trim() || null,
|
||||
},
|
||||
{ onSuccess: onClose },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBackdrop
|
||||
onClose={onClose}
|
||||
aria-label={t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_ADD_BUTTON)}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
data-testid="add-canvas-extension-modal"
|
||||
className="relative flex w-[520px] max-w-[90vw] max-h-[85vh] flex-col rounded-xl border border-[var(--oh-border)] bg-base-secondary"
|
||||
>
|
||||
<ModalCloseButton
|
||||
onClose={onClose}
|
||||
testId="add-canvas-extension-modal-close"
|
||||
/>
|
||||
<header className="flex-shrink-0 px-6 pb-4 pt-6">
|
||||
<h2 className={cn("pr-6", modalTitleLgClassName)}>
|
||||
{t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_ADD_BUTTON)}
|
||||
</h2>
|
||||
<p className="mt-4 text-sm text-tertiary-light">
|
||||
{t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_ADD_MODAL_INTRO)}
|
||||
</p>
|
||||
</header>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 custom-scrollbar">
|
||||
<SettingsInput
|
||||
testId="add-canvas-extension-source-input"
|
||||
label={t(I18nKey.SETTINGS$PLUGINS_SOURCE_LABEL)}
|
||||
type="text"
|
||||
value={source}
|
||||
onChange={setSource}
|
||||
placeholder={t(I18nKey.SETTINGS$PLUGINS_SOURCE_PLACEHOLDER)}
|
||||
showRequiredTag
|
||||
/>
|
||||
<SettingsInput
|
||||
testId="add-canvas-extension-ref-input"
|
||||
label={t(I18nKey.SETTINGS$PLUGINS_REF_LABEL)}
|
||||
type="text"
|
||||
value={ref}
|
||||
onChange={setRef}
|
||||
showOptionalTag
|
||||
/>
|
||||
<SettingsInput
|
||||
testId="add-canvas-extension-repo-path-input"
|
||||
label={t(I18nKey.SETTINGS$PLUGINS_REPO_PATH_LABEL)}
|
||||
type="text"
|
||||
value={repoPath}
|
||||
onChange={setRepoPath}
|
||||
showOptionalTag
|
||||
/>
|
||||
</div>
|
||||
<footer className="flex flex-shrink-0 justify-end gap-2 px-6 pb-6 pt-4">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
testId="add-canvas-extension-dismiss"
|
||||
>
|
||||
{t(I18nKey.BUTTON$CLOSE)}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
testId="add-canvas-extension-submit"
|
||||
isDisabled={!canSubmit}
|
||||
>
|
||||
{t(
|
||||
install.isPending
|
||||
? I18nKey.SETTINGS$PLUGINS_INSTALLING
|
||||
: I18nKey.SETTINGS$PLUGINS_INSTALL,
|
||||
)}
|
||||
</BrandButton>
|
||||
</footer>
|
||||
</form>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { ToggleSwitch } from "#/ui/toggle-switch";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import type { InstalledCanvasExtensionInfo } from "#/types/canvas-extension";
|
||||
import {
|
||||
extensionModuleCardPillClassName,
|
||||
extensionModuleCardSurfaceClassName,
|
||||
} from "#/utils/extension-module-card-classes";
|
||||
|
||||
interface CanvasExtensionCardProps {
|
||||
extension: InstalledCanvasExtensionInfo;
|
||||
isBusy: boolean;
|
||||
onToggle: () => void;
|
||||
onUninstall: () => void;
|
||||
}
|
||||
|
||||
export function CanvasExtensionCard({
|
||||
extension,
|
||||
isBusy,
|
||||
onToggle,
|
||||
onUninstall,
|
||||
}: CanvasExtensionCardProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const pages = extension.manifest?.contributes?.pages ?? [];
|
||||
const displayName = extension.manifest?.display_name || extension.name;
|
||||
|
||||
return (
|
||||
<article
|
||||
data-testid={`canvas-extension-card-${extension.name}`}
|
||||
className={`flex min-w-0 flex-col gap-4 p-4 ${extensionModuleCardSurfaceClassName}`}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-sm font-semibold text-white">
|
||||
{displayName}
|
||||
</h3>
|
||||
<p className="mt-0.5 truncate text-xs text-tertiary-alt">
|
||||
{extension.source}
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
enabled={extension.enabled}
|
||||
label={t(
|
||||
extension.enabled ? I18nKey.COMMON$DISABLE : I18nKey.COMMON$ENABLE,
|
||||
)}
|
||||
onToggle={onToggle}
|
||||
className={isBusy ? "pointer-events-none opacity-50" : undefined}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{extension.description || extension.manifest?.description ? (
|
||||
<p className="text-xs leading-relaxed text-tertiary-light">
|
||||
{extension.description || extension.manifest?.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className={extensionModuleCardPillClassName}>
|
||||
{extension.enabled
|
||||
? t(I18nKey.SETTINGS$SKILLS_ENABLED)
|
||||
: t(I18nKey.SETTINGS$SKILLS_DISABLED)}
|
||||
</span>
|
||||
<span className={extensionModuleCardPillClassName}>
|
||||
{t(I18nKey.SETTINGS$SKILLS_VERSION, {
|
||||
version: extension.version,
|
||||
})}
|
||||
</span>
|
||||
{pages.length ? (
|
||||
<span className={extensionModuleCardPillClassName}>
|
||||
{t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_PAGES)}: {pages.length}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<dl className="grid min-w-0 grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
{extension.resolved_ref ? (
|
||||
<>
|
||||
<dt className="text-tertiary-alt">
|
||||
{t(I18nKey.SETTINGS$PLUGINS_REF_LABEL)}
|
||||
</dt>
|
||||
<dd className="min-w-0 truncate font-mono text-tertiary-light">
|
||||
{extension.resolved_ref}
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
{extension.repo_path ? (
|
||||
<>
|
||||
<dt className="text-tertiary-alt">
|
||||
{t(I18nKey.SETTINGS$PLUGINS_REPO_PATH_LABEL)}
|
||||
</dt>
|
||||
<dd className="min-w-0 truncate text-tertiary-light">
|
||||
{extension.repo_path}
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
|
||||
{pages.length ? (
|
||||
<section className="flex min-w-0 flex-col gap-2">
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{pages.map((page) => (
|
||||
<li key={page.id} className={extensionModuleCardPillClassName}>
|
||||
{page.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<footer className="flex justify-end gap-2 border-t border-[var(--oh-border)] pt-3">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="ghost-danger"
|
||||
testId={`canvas-extension-uninstall-${extension.name}`}
|
||||
isDisabled={isBusy}
|
||||
onClick={onUninstall}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$PLUGINS_UNINSTALL)}
|
||||
</BrandButton>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import CanvasExtensionsService from "#/api/canvas-extensions-service";
|
||||
import {
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
import type {
|
||||
CanvasExtensionHost,
|
||||
InstalledCanvasExtensionInfo,
|
||||
} from "#/types/canvas-extension";
|
||||
import {
|
||||
CanvasExtensionsRuntimeProvider,
|
||||
useCanvasExtensionsRuntime,
|
||||
} from "./canvas-extensions-runtime";
|
||||
|
||||
const backend: Backend = {
|
||||
id: "extension-backend",
|
||||
name: "Extension backend",
|
||||
host: "http://127.0.0.1:8000",
|
||||
apiKey: "test-key",
|
||||
kind: "local",
|
||||
};
|
||||
|
||||
const extension: InstalledCanvasExtensionInfo = {
|
||||
name: "demo-extension",
|
||||
version: "0.1.0",
|
||||
enabled: true,
|
||||
source: "github:example/demo",
|
||||
resolved_ref: "abc123",
|
||||
installed_at: "2026-08-01T00:00:00Z",
|
||||
install_path: "/tmp/demo-extension",
|
||||
manifest: {
|
||||
schema_version: 1,
|
||||
name: "demo-extension",
|
||||
display_name: "Demo extension",
|
||||
version: "0.1.0",
|
||||
entrypoint: "dist/extension.js",
|
||||
contributes: {
|
||||
pages: [
|
||||
{
|
||||
id: "dashboard",
|
||||
title: "Dashboard",
|
||||
path: "/dashboard",
|
||||
nav_label: "Demo dashboard",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function RuntimeProbe() {
|
||||
const runtime = useCanvasExtensionsRuntime();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="page-count">{runtime.pages.length}</span>
|
||||
<span data-testid="page-href">{runtime.pages[0]?.href}</span>
|
||||
<span data-testid="runtime-error">
|
||||
{runtime.errors.get(extension.name)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRuntime(
|
||||
moduleLoader: (source: string) => Promise<{
|
||||
activate: (host: CanvasExtensionHost) => void | (() => void);
|
||||
}>,
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ActiveBackendProvider>
|
||||
<MemoryRouter>
|
||||
<CanvasExtensionsRuntimeProvider moduleLoader={moduleLoader}>
|
||||
<RuntimeProbe />
|
||||
</CanvasExtensionsRuntimeProvider>
|
||||
</MemoryRouter>
|
||||
</ActiveBackendProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("CanvasExtensionsRuntimeProvider", () => {
|
||||
beforeEach(() => {
|
||||
setRegisteredBackends([backend]);
|
||||
setActiveSelection({ backendId: backend.id });
|
||||
vi.spyOn(CanvasExtensionsService, "listInstalled").mockResolvedValue([
|
||||
extension,
|
||||
]);
|
||||
vi.spyOn(CanvasExtensionsService, "fetchBundle").mockResolvedValue(
|
||||
"fixture source",
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
setActiveSelection(null);
|
||||
setRegisteredBackends([]);
|
||||
});
|
||||
|
||||
it("activates enabled extensions and admits declared page registrations", async () => {
|
||||
const disposeActivation = vi.fn();
|
||||
const moduleLoader = vi.fn().mockResolvedValue({
|
||||
activate: (host: CanvasExtensionHost) => {
|
||||
host.registerPage("dashboard", () => undefined);
|
||||
return disposeActivation;
|
||||
},
|
||||
});
|
||||
|
||||
const rendered = renderRuntime(moduleLoader);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("page-count")).toHaveTextContent("1"),
|
||||
);
|
||||
expect(screen.getByTestId("page-href")).toHaveTextContent(
|
||||
"/extensions/demo-extension/dashboard",
|
||||
);
|
||||
expect(CanvasExtensionsService.fetchBundle).toHaveBeenCalledWith(
|
||||
extension.name,
|
||||
expect.objectContaining({ id: backend.id }),
|
||||
);
|
||||
|
||||
rendered.unmount();
|
||||
expect(disposeActivation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects registrations that were not declared in the manifest", async () => {
|
||||
const moduleLoader = vi.fn().mockResolvedValue({
|
||||
activate: (host: CanvasExtensionHost) => {
|
||||
host.registerPage("surprise", () => undefined);
|
||||
},
|
||||
});
|
||||
|
||||
renderRuntime(moduleLoader);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("runtime-error")).toHaveTextContent(
|
||||
'registered undeclared page "surprise"',
|
||||
),
|
||||
);
|
||||
expect(screen.getByTestId("page-count")).toHaveTextContent("0");
|
||||
});
|
||||
|
||||
it("degrades gracefully when the backend response has no manifest", async () => {
|
||||
vi.mocked(CanvasExtensionsService.listInstalled).mockResolvedValue([
|
||||
{ ...extension, manifest: null },
|
||||
]);
|
||||
const moduleLoader = vi.fn().mockResolvedValue({
|
||||
activate: (host: CanvasExtensionHost) => {
|
||||
host.registerPage("dashboard", () => undefined);
|
||||
},
|
||||
});
|
||||
|
||||
renderRuntime(moduleLoader);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("runtime-error")).toHaveTextContent(
|
||||
'registered undeclared page "dashboard"',
|
||||
),
|
||||
);
|
||||
expect(screen.getByTestId("page-count")).toHaveTextContent("0");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import CanvasExtensionsService from "#/api/canvas-extensions-service";
|
||||
import { useActiveBackend } from "#/contexts/active-backend-context";
|
||||
import { loadCanvasExtensionModule } from "#/extensions/canvas-extension-module-loader";
|
||||
import { useCanvasExtensions } from "#/hooks/query/use-canvas-extensions";
|
||||
import {
|
||||
CANVAS_EXTENSION_HOST_API_VERSION,
|
||||
type CanvasExtensionDispose,
|
||||
type CanvasExtensionHost,
|
||||
type CanvasExtensionModule,
|
||||
type CanvasExtensionPageContribution,
|
||||
type CanvasExtensionPageMount,
|
||||
type InstalledCanvasExtensionInfo,
|
||||
} from "#/types/canvas-extension";
|
||||
|
||||
export interface RegisteredCanvasExtensionPage {
|
||||
extension: InstalledCanvasExtensionInfo;
|
||||
contribution: CanvasExtensionPageContribution;
|
||||
mount: CanvasExtensionPageMount;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface CanvasExtensionsRuntimeValue {
|
||||
pages: RegisteredCanvasExtensionPage[];
|
||||
activating: boolean;
|
||||
errors: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
const EMPTY_RUNTIME: CanvasExtensionsRuntimeValue = {
|
||||
pages: [],
|
||||
activating: false,
|
||||
errors: new Map(),
|
||||
};
|
||||
|
||||
const CanvasExtensionsRuntimeContext =
|
||||
React.createContext<CanvasExtensionsRuntimeValue>(EMPTY_RUNTIME);
|
||||
|
||||
export function useCanvasExtensionsRuntime(): CanvasExtensionsRuntimeValue {
|
||||
return React.useContext(CanvasExtensionsRuntimeContext);
|
||||
}
|
||||
|
||||
function isValidSegment(value: string): boolean {
|
||||
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
|
||||
}
|
||||
|
||||
export function buildCanvasExtensionPageHref(
|
||||
extensionName: string,
|
||||
contributionPath: string,
|
||||
): string {
|
||||
return `/extensions/${encodeURIComponent(extensionName)}/${contributionPath
|
||||
.split("/")
|
||||
.map(encodeURIComponent)
|
||||
.join("/")}`;
|
||||
}
|
||||
|
||||
function getDeclaredPage(
|
||||
extension: InstalledCanvasExtensionInfo,
|
||||
contributionId: string,
|
||||
): CanvasExtensionPageContribution {
|
||||
const contribution = extension.manifest?.contributes?.pages?.find(
|
||||
(page) => page.id === contributionId,
|
||||
);
|
||||
if (!contribution) {
|
||||
throw new Error(
|
||||
`Extension ${extension.name} registered undeclared page "${contributionId}".`,
|
||||
);
|
||||
}
|
||||
// The backend declares page paths as absolute routes (e.g. "/dashboard");
|
||||
// normalize to the relative form used for hrefs and route matching.
|
||||
const normalizedPath = contribution.path.replace(/^\/+/, "");
|
||||
if (
|
||||
!isValidSegment(extension.name) ||
|
||||
!isValidSegment(contribution.id) ||
|
||||
!normalizedPath.split("/").every(isValidSegment)
|
||||
) {
|
||||
throw new Error(
|
||||
`Extension ${extension.name} has an invalid page name, id, or path.`,
|
||||
);
|
||||
}
|
||||
return { ...contribution, path: normalizedPath };
|
||||
}
|
||||
|
||||
type CanvasExtensionModuleLoader = (
|
||||
source: string,
|
||||
) => Promise<CanvasExtensionModule>;
|
||||
|
||||
interface CanvasExtensionsRuntimeProviderProps {
|
||||
children: React.ReactNode;
|
||||
/** Test seam for environments that cannot import browser Blob URLs. */
|
||||
moduleLoader?: CanvasExtensionModuleLoader;
|
||||
}
|
||||
|
||||
export function CanvasExtensionsRuntimeProvider({
|
||||
children,
|
||||
moduleLoader = loadCanvasExtensionModule,
|
||||
}: CanvasExtensionsRuntimeProviderProps) {
|
||||
const active = useActiveBackend();
|
||||
const navigate = useNavigate();
|
||||
const query = useCanvasExtensions();
|
||||
const [pages, setPages] = React.useState<RegisteredCanvasExtensionPage[]>([]);
|
||||
const [errors, setErrors] = React.useState<ReadonlyMap<string, string>>(
|
||||
new Map(),
|
||||
);
|
||||
const [activating, setActivating] = React.useState(false);
|
||||
|
||||
const enabledExtensions = React.useMemo(
|
||||
() => (query.data ?? []).filter((extension) => extension.enabled),
|
||||
[query.data],
|
||||
);
|
||||
|
||||
// `useActiveBackend` can synthesize a fresh `backend` object on every render
|
||||
// (e.g. when mounted without an <ActiveBackendProvider>), and refetches
|
||||
// produce new extension arrays with identical content. The activation effect
|
||||
// therefore keys on this value signature — backend identity plus the enabled
|
||||
// inventory — and reads the current objects from refs, so referential churn
|
||||
// never tears down and re-activates extensions.
|
||||
const activationSignature = React.useMemo(
|
||||
() =>
|
||||
JSON.stringify({
|
||||
backendId: active.backend.id,
|
||||
backendKind: active.backend.kind,
|
||||
connectionRevision: active.backend.connectionRevision ?? 0,
|
||||
orgId: active.orgId,
|
||||
extensions: enabledExtensions.map((extension) => ({
|
||||
name: extension.name,
|
||||
version: extension.version,
|
||||
resolvedRef: extension.resolved_ref ?? null,
|
||||
pages: extension.manifest?.contributes?.pages ?? [],
|
||||
})),
|
||||
}),
|
||||
[
|
||||
active.backend.id,
|
||||
active.backend.kind,
|
||||
active.backend.connectionRevision,
|
||||
active.orgId,
|
||||
enabledExtensions,
|
||||
],
|
||||
);
|
||||
const activeRef = React.useRef(active);
|
||||
activeRef.current = active;
|
||||
const enabledExtensionsRef = React.useRef(enabledExtensions);
|
||||
enabledExtensionsRef.current = enabledExtensions;
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const disposers: CanvasExtensionDispose[] = [];
|
||||
const { backend, orgId } = activeRef.current;
|
||||
const extensionsToActivate = enabledExtensionsRef.current;
|
||||
setPages([]);
|
||||
setErrors(new Map());
|
||||
setActivating(extensionsToActivate.length > 0);
|
||||
|
||||
const activateExtension = async (
|
||||
extension: InstalledCanvasExtensionInfo,
|
||||
) => {
|
||||
const registeredPages = new Map<string, RegisteredCanvasExtensionPage>();
|
||||
const registrationDisposers: CanvasExtensionDispose[] = [];
|
||||
try {
|
||||
const source = await CanvasExtensionsService.fetchBundle(
|
||||
extension.name,
|
||||
backend,
|
||||
);
|
||||
if (cancelled) return;
|
||||
const extensionModule = await moduleLoader(source);
|
||||
if (cancelled) return;
|
||||
|
||||
const host: CanvasExtensionHost = {
|
||||
apiVersion: CANVAS_EXTENSION_HOST_API_VERSION,
|
||||
extension: Object.freeze({
|
||||
name: extension.name,
|
||||
version: extension.version,
|
||||
resolvedRef: extension.resolved_ref ?? null,
|
||||
}),
|
||||
backend: Object.freeze({
|
||||
id: backend.id,
|
||||
kind: backend.kind,
|
||||
orgId,
|
||||
}),
|
||||
registerPage: (contributionId, mount) => {
|
||||
if (registeredPages.has(contributionId)) {
|
||||
throw new Error(
|
||||
`Extension ${extension.name} registered page "${contributionId}" more than once.`,
|
||||
);
|
||||
}
|
||||
const contribution = getDeclaredPage(extension, contributionId);
|
||||
const page: RegisteredCanvasExtensionPage = {
|
||||
extension,
|
||||
contribution,
|
||||
mount,
|
||||
href: buildCanvasExtensionPageHref(
|
||||
extension.name,
|
||||
contribution.path,
|
||||
),
|
||||
};
|
||||
registeredPages.set(contributionId, page);
|
||||
const unregister = () => registeredPages.delete(contributionId);
|
||||
registrationDisposers.push(unregister);
|
||||
return unregister;
|
||||
},
|
||||
navigate: (path) => navigate(path),
|
||||
agentServer: {
|
||||
request: (request) =>
|
||||
CanvasExtensionsService.requestAgentServer(request, backend),
|
||||
},
|
||||
};
|
||||
|
||||
const disposeActivation = await extensionModule.activate(host);
|
||||
if (cancelled) {
|
||||
if (typeof disposeActivation === "function") disposeActivation();
|
||||
return;
|
||||
}
|
||||
if (typeof disposeActivation === "function") {
|
||||
disposers.push(disposeActivation);
|
||||
}
|
||||
disposers.push(...registrationDisposers);
|
||||
setPages((current) => [
|
||||
...current.filter((page) => page.extension.name !== extension.name),
|
||||
...registeredPages.values(),
|
||||
]);
|
||||
} catch (error) {
|
||||
registrationDisposers.forEach((dispose) => dispose());
|
||||
if (cancelled) return;
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Extension activation failed.";
|
||||
setErrors((current) => {
|
||||
const next = new Map(current);
|
||||
next.set(extension.name, message);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
void Promise.all(extensionsToActivate.map(activateExtension)).finally(
|
||||
() => {
|
||||
if (!cancelled) setActivating(false);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
setPages([]);
|
||||
for (const dispose of disposers.reverse()) {
|
||||
try {
|
||||
dispose();
|
||||
} catch (error) {
|
||||
console.error("Canvas Extension cleanup failed", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [activationSignature, moduleLoader, navigate]);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ pages, activating, errors }),
|
||||
[pages, activating, errors],
|
||||
);
|
||||
|
||||
return (
|
||||
<CanvasExtensionsRuntimeContext.Provider value={value}>
|
||||
{children}
|
||||
</CanvasExtensionsRuntimeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Plus,
|
||||
Server,
|
||||
Settings,
|
||||
PanelsTopLeft,
|
||||
} from "lucide-react";
|
||||
import { OpenHandsLogoButton } from "#/components/shared/buttons/openhands-logo-button";
|
||||
import { NavigationLink } from "#/components/shared/navigation-link";
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
sidebarNavListClassName,
|
||||
sidebarNavRowClassName,
|
||||
} from "./sidebar-layout";
|
||||
import { useCanvasExtensionsRuntime } from "#/components/features/canvas-extensions/canvas-extensions-runtime";
|
||||
|
||||
const ICON_SIZE = 18;
|
||||
const SIDEBAR_LOGO_WIDTH = 34;
|
||||
@@ -83,6 +85,7 @@ export function SidebarRailBody({
|
||||
onOpenManageBackends,
|
||||
}: SidebarRailBodyProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const { pages: canvasExtensionPages } = useCanvasExtensionsRuntime();
|
||||
const backendCloseTimerRef = collapsedBackendCloseTimer;
|
||||
|
||||
return (
|
||||
@@ -216,6 +219,16 @@ export function SidebarRailBody({
|
||||
icon={<AutomationsIcon width={ICON_SIZE} height={ICON_SIZE} />}
|
||||
/>
|
||||
)}
|
||||
{canvasExtensionPages.map((page) => (
|
||||
<SidebarNavLink
|
||||
key={`${page.extension.name}:${page.contribution.id}`}
|
||||
to={page.href}
|
||||
label={page.contribution.nav_label || page.contribution.title}
|
||||
testId={`sidebar-canvas-extension-${page.extension.name}-${page.contribution.id}`}
|
||||
collapsed={collapsed}
|
||||
icon={<PanelsTopLeft width={ICON_SIZE} height={ICON_SIZE} />}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<SidebarConversationList collapsed={collapsed} />
|
||||
|
||||
@@ -181,6 +181,7 @@ export function Sidebar() {
|
||||
currentPath === "/customize" ||
|
||||
currentPath.startsWith("/skills") ||
|
||||
currentPath === "/plugins" ||
|
||||
currentPath === "/extensions" ||
|
||||
currentPath === "/mcp";
|
||||
|
||||
const railBodyProps = {
|
||||
|
||||
@@ -17,8 +17,8 @@ import { EXTENSIONS_NAV_ITEMS } from "./extensions-navigation";
|
||||
|
||||
/** Only the Skills item points to a cloud-hosted page today. */
|
||||
const CLOUD_LINKED_EXTENSION_PATH = "/skills";
|
||||
/** Plugins are not available on Cloud backends, so this item is hidden there. */
|
||||
const CLOUD_HIDDEN_EXTENSION_PATH = "/plugins";
|
||||
/** Backend-installed artifacts are not available on Cloud backends yet. */
|
||||
const CLOUD_HIDDEN_EXTENSION_PATHS = new Set(["/plugins", "/extensions"]);
|
||||
|
||||
export function ExtensionsMobileHub() {
|
||||
const { t } = useTranslation("openhands");
|
||||
@@ -35,7 +35,7 @@ export function ExtensionsMobileHub() {
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{EXTENSIONS_NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
!(item.to === CLOUD_HIDDEN_EXTENSION_PATH && isCloudBackend),
|
||||
!(CLOUD_HIDDEN_EXTENSION_PATHS.has(item.to) && isCloudBackend),
|
||||
).map((item) => {
|
||||
if (item.to === CLOUD_LINKED_EXTENSION_PATH && isCloudBackend) {
|
||||
const cloudSkillsUrl = `${backend.host.replace(/\/+$/, "")}/settings/skills`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Blocks, ExternalLink } from "lucide-react";
|
||||
import { NavigationLink } from "#/components/shared/navigation-link";
|
||||
import { cn } from "#/utils/utils";
|
||||
import SkillsIcon from "#/icons/skills.svg?react";
|
||||
@@ -15,8 +15,8 @@ import { isNoBackend } from "#/api/backend-registry/active-store";
|
||||
|
||||
/** Only the Skills item points to a cloud-hosted page today. */
|
||||
const CLOUD_LINKED_EXTENSION_PATH = "/skills";
|
||||
/** Plugins are not available on Cloud backends, so this item is hidden there. */
|
||||
const CLOUD_HIDDEN_EXTENSION_PATH = "/plugins";
|
||||
/** Backend-installed artifacts are not available on Cloud backends yet. */
|
||||
const CLOUD_HIDDEN_EXTENSION_PATHS = new Set(["/plugins", "/extensions"]);
|
||||
|
||||
interface ExtensionNavItem {
|
||||
to: string;
|
||||
@@ -62,6 +62,12 @@ export const EXTENSIONS_NAV_ITEMS: ExtensionNavItem[] = [
|
||||
),
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
to: "/extensions",
|
||||
label: "Extensions",
|
||||
icon: <Blocks width={16} height={16} aria-hidden="true" />,
|
||||
end: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function ExtensionsNavigation() {
|
||||
@@ -81,7 +87,7 @@ export function ExtensionsNavigation() {
|
||||
<div className="flex flex-col gap-0.5 pt-0.5">
|
||||
{EXTENSIONS_NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
!(item.to === CLOUD_HIDDEN_EXTENSION_PATH && isCloudBackend),
|
||||
!(CLOUD_HIDDEN_EXTENSION_PATHS.has(item.to) && isCloudBackend),
|
||||
).map((item) => {
|
||||
const isCloudSkillsLink =
|
||||
item.to === CLOUD_LINKED_EXTENSION_PATH && isCloudBackend;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertCanvasExtensionModule,
|
||||
InvalidCanvasExtensionModuleError,
|
||||
} from "#/extensions/canvas-extension-module-loader";
|
||||
|
||||
describe("assertCanvasExtensionModule", () => {
|
||||
it("accepts a module namespace with an activate export", () => {
|
||||
expect(() =>
|
||||
assertCanvasExtensionModule({ activate: () => undefined }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects modules that do not implement the host lifecycle", () => {
|
||||
expect(() => assertCanvasExtensionModule({ default: {} })).toThrow(
|
||||
InvalidCanvasExtensionModuleError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { CanvasExtensionModule } from "#/types/canvas-extension";
|
||||
|
||||
export class InvalidCanvasExtensionModuleError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "InvalidCanvasExtensionModuleError";
|
||||
}
|
||||
}
|
||||
|
||||
export function assertCanvasExtensionModule(
|
||||
value: unknown,
|
||||
): asserts value is CanvasExtensionModule {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
!("activate" in value) ||
|
||||
typeof value.activate !== "function"
|
||||
) {
|
||||
throw new InvalidCanvasExtensionModuleError(
|
||||
'Canvas Extension entrypoint must export an "activate" function.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import an authenticated, self-contained ESM bundle without putting the
|
||||
* Agent Server session key in a URL. The source has already been fetched by
|
||||
* Canvas through its authenticated HTTP client.
|
||||
*/
|
||||
export async function loadCanvasExtensionModule(
|
||||
source: string,
|
||||
): Promise<CanvasExtensionModule> {
|
||||
const blob = new Blob([source], { type: "text/javascript" });
|
||||
const moduleUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
const imported: unknown = await import(/* @vite-ignore */ moduleUrl);
|
||||
assertCanvasExtensionModule(imported);
|
||||
return imported;
|
||||
} finally {
|
||||
URL.revokeObjectURL(moduleUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CanvasExtensionsService from "#/api/canvas-extensions-service";
|
||||
import type { InstallCanvasExtensionRequest } from "#/types/canvas-extension";
|
||||
import { CANVAS_EXTENSIONS_QUERY_KEYS } from "#/hooks/query/query-keys";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||
|
||||
function useInvalidateCanvasExtensions() {
|
||||
const queryClient = useQueryClient();
|
||||
return () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: CANVAS_EXTENSIONS_QUERY_KEYS.all,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstallCanvasExtension() {
|
||||
const invalidate = useInvalidateCanvasExtensions();
|
||||
const { t } = useTranslation("openhands");
|
||||
return useMutation({
|
||||
mutationFn: (request: InstallCanvasExtensionRequest) =>
|
||||
CanvasExtensionsService.install(request),
|
||||
onSuccess: () => {
|
||||
void invalidate();
|
||||
displaySuccessToast(
|
||||
t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_INSTALL_SUCCESS),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetCanvasExtensionEnabled() {
|
||||
const invalidate = useInvalidateCanvasExtensions();
|
||||
return useMutation({
|
||||
mutationFn: ({ name, enabled }: { name: string; enabled: boolean }) =>
|
||||
CanvasExtensionsService.setEnabled(name, enabled),
|
||||
onSuccess: () => void invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUninstallCanvasExtension() {
|
||||
const invalidate = useInvalidateCanvasExtensions();
|
||||
const { t } = useTranslation("openhands");
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => CanvasExtensionsService.uninstall(name),
|
||||
onSuccess: () => {
|
||||
void invalidate();
|
||||
displaySuccessToast(
|
||||
t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_UNINSTALL_SUCCESS),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -54,6 +54,22 @@ export const PLUGINS_QUERY_KEYS = {
|
||||
local: ["plugins-local"] as const,
|
||||
} as const;
|
||||
|
||||
export const CANVAS_EXTENSIONS_QUERY_KEYS = {
|
||||
all: ["canvas-extensions"] as const,
|
||||
installed: (
|
||||
backendId: string,
|
||||
orgId: string | null,
|
||||
connectionRevision: number,
|
||||
) =>
|
||||
[
|
||||
"canvas-extensions",
|
||||
"installed",
|
||||
backendId,
|
||||
orgId,
|
||||
connectionRevision,
|
||||
] as const,
|
||||
} as const;
|
||||
|
||||
export const SETUP_QUERY_KEYS = {
|
||||
/** What the deployment supports. The same answer for every setup entry. */
|
||||
capabilities: () => ["setup-capabilities"] as const,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import CanvasExtensionsService from "#/api/canvas-extensions-service";
|
||||
import { isNoBackend } from "#/api/backend-registry/active-store";
|
||||
import { useActiveBackend } from "#/contexts/active-backend-context";
|
||||
import { CANVAS_EXTENSIONS_QUERY_KEYS } from "#/hooks/query/query-keys";
|
||||
|
||||
interface UseCanvasExtensionsOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useCanvasExtensions(options: UseCanvasExtensionsOptions = {}) {
|
||||
const active = useActiveBackend();
|
||||
const connectionRevision = active.backend.connectionRevision ?? 0;
|
||||
const backendSupported =
|
||||
!isNoBackend(active.backend) && active.backend.kind === "local";
|
||||
|
||||
return useQuery({
|
||||
queryKey: CANVAS_EXTENSIONS_QUERY_KEYS.installed(
|
||||
active.backend.id,
|
||||
active.orgId,
|
||||
connectionRevision,
|
||||
),
|
||||
queryFn: () => CanvasExtensionsService.listInstalled(),
|
||||
enabled: (options.enabled ?? true) && backendSupported,
|
||||
retry: false,
|
||||
staleTime: 0,
|
||||
refetchOnWindowFocus: true,
|
||||
meta: {
|
||||
backendId: active.backend.id,
|
||||
// An old backend returning 404 is an expected capability result. The
|
||||
// management page renders it and the global runtime stays quiet.
|
||||
disableToast: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -32639,6 +32639,193 @@
|
||||
"tr": "Bu eklenti paketindeki yetenekler",
|
||||
"uk": "Навички в цьому пакеті плагіна"
|
||||
},
|
||||
"SETTINGS$CANVAS_EXTENSIONS_PAGE_DESCRIPTION": {
|
||||
"en": "Install trusted UI that runs inside Canvas for this backend. Installed extensions stay off until you enable them here.",
|
||||
"ja": "このバックエンドの Canvas 内で動作する信頼済み UI をインストールします。インストールした拡張機能は、ここで有効にするまでオフのままです。",
|
||||
"zh-CN": "为此后端安装在 Canvas 内运行的受信任 UI。已安装的扩展在您于此处启用前将保持关闭。",
|
||||
"zh-TW": "為此後端安裝在 Canvas 內執行的受信任 UI。已安裝的擴充功能在您於此處啟用前將保持關閉。",
|
||||
"ko-KR": "이 백엔드의 Canvas 안에서 실행되는 신뢰할 수 있는 UI를 설치합니다. 설치된 확장 기능은 여기에서 활성화할 때까지 꺼진 상태로 유지됩니다.",
|
||||
"no": "Installer klarert brukergrensesnitt som kjører i Canvas for denne bakenden. Installerte utvidelser forblir av til du aktiverer dem her.",
|
||||
"ar": "ثبّت واجهة مستخدم موثوقة تعمل داخل Canvas لهذه الواجهة الخلفية. تظل الامتدادات المثبتة متوقفة حتى تفعّلها هنا.",
|
||||
"de": "Installieren Sie vertrauenswürdige UI, die für dieses Backend direkt in Canvas ausgeführt wird. Installierte Erweiterungen bleiben deaktiviert, bis Sie sie hier aktivieren.",
|
||||
"fr": "Installez une interface de confiance qui s’exécute dans Canvas pour ce backend. Les extensions installées restent désactivées jusqu’à ce que vous les activiez ici.",
|
||||
"it": "Installa un'interfaccia attendibile che viene eseguita in Canvas per questo backend. Le estensioni installate restano disattivate finché non le abiliti qui.",
|
||||
"pt": "Instale uma interface confiável executada dentro do Canvas para este backend. As extensões instaladas permanecem desativadas até você ativá-las aqui.",
|
||||
"es": "Instala una interfaz de confianza que se ejecuta dentro de Canvas para este backend. Las extensiones instaladas permanecen desactivadas hasta que las habilites aquí.",
|
||||
"tr": "Bu arka uç için Canvas içinde çalışan güvenilir bir arayüz kurun. Kurulan uzantılar siz burada etkinleştirene kadar kapalı kalır.",
|
||||
"uk": "Установлюйте довірений інтерфейс, що працює всередині Canvas для цього бекенда. Установлені розширення залишаються вимкненими, доки ви не ввімкнете їх тут.",
|
||||
"ca": "Instal·la una interfície de confiança que s'executa dins del Canvas per a aquest backend. Les extensions instal·lades romanen desactivades fins que les activis aquí."
|
||||
},
|
||||
"SETTINGS$CANVAS_EXTENSIONS_ADD_BUTTON": {
|
||||
"en": "Add extension",
|
||||
"ja": "拡張機能を追加",
|
||||
"zh-CN": "添加扩展",
|
||||
"zh-TW": "新增擴充功能",
|
||||
"ko-KR": "확장 기능 추가",
|
||||
"no": "Legg til utvidelse",
|
||||
"ar": "إضافة امتداد",
|
||||
"de": "Erweiterung hinzufügen",
|
||||
"fr": "Ajouter une extension",
|
||||
"it": "Aggiungi estensione",
|
||||
"pt": "Adicionar extensão",
|
||||
"es": "Agregar extensión",
|
||||
"tr": "Uzantı ekle",
|
||||
"uk": "Додати розширення",
|
||||
"ca": "Afegeix una extensió"
|
||||
},
|
||||
"SETTINGS$CANVAS_EXTENSIONS_EMPTY": {
|
||||
"en": "No Canvas Extensions are installed on this backend.",
|
||||
"ja": "このバックエンドには Canvas 拡張機能がインストールされていません。",
|
||||
"zh-CN": "此后端未安装任何 Canvas 扩展。",
|
||||
"zh-TW": "此後端未安裝任何 Canvas 擴充功能。",
|
||||
"ko-KR": "이 백엔드에 설치된 Canvas 확장 기능이 없습니다.",
|
||||
"no": "Ingen Canvas-utvidelser er installert på denne bakenden.",
|
||||
"ar": "لا توجد امتدادات Canvas مثبتة على هذه الواجهة الخلفية.",
|
||||
"de": "Auf diesem Backend sind keine Canvas-Erweiterungen installiert.",
|
||||
"fr": "Aucune extension Canvas n’est installée sur ce backend.",
|
||||
"it": "Nessuna estensione Canvas è installata su questo backend.",
|
||||
"pt": "Nenhuma extensão do Canvas está instalada neste backend.",
|
||||
"es": "No hay extensiones de Canvas instaladas en este backend.",
|
||||
"tr": "Bu arka uçta kurulu Canvas Uzantısı yok.",
|
||||
"uk": "На цьому бекенді не встановлено жодного розширення Canvas.",
|
||||
"ca": "No hi ha cap extensió del Canvas instal·lada en aquest backend."
|
||||
},
|
||||
"SETTINGS$CANVAS_EXTENSIONS_TRUST_NOTICE": {
|
||||
"en": "Enabled extensions are trusted code with full access to this Canvas session.",
|
||||
"ja": "有効な拡張機能は、この Canvas セッションに完全にアクセスできる信頼済みコードです。",
|
||||
"zh-CN": "已启用的扩展是受信任代码,可完全访问此 Canvas 会话。",
|
||||
"zh-TW": "已啟用的擴充功能是受信任程式碼,可完整存取此 Canvas 工作階段。",
|
||||
"ko-KR": "활성화된 확장 기능은 이 Canvas 세션에 대한 전체 액세스 권한이 있는 신뢰할 수 있는 코드입니다.",
|
||||
"no": "Aktiverte utvidelser er klarert kode med full tilgang til denne Canvas-økten.",
|
||||
"ar": "الامتدادات المفعّلة هي تعليمات برمجية موثوقة تتمتع بوصول كامل إلى جلسة Canvas هذه.",
|
||||
"de": "Aktivierte Erweiterungen sind vertrauenswürdiger Code mit vollständigem Zugriff auf diese Canvas-Sitzung.",
|
||||
"fr": "Les extensions activées sont du code de confiance avec un accès complet à cette session Canvas.",
|
||||
"it": "Le estensioni abilitate sono codice attendibile con accesso completo a questa sessione Canvas.",
|
||||
"pt": "As extensões ativadas são código confiável com acesso total a esta sessão do Canvas.",
|
||||
"es": "Las extensiones habilitadas son código de confianza con acceso completo a esta sesión de Canvas.",
|
||||
"tr": "Etkin uzantılar, bu Canvas oturumuna tam erişimi olan güvenilir kodlardır.",
|
||||
"uk": "Увімкнені розширення є довіреним кодом із повним доступом до цього сеансу Canvas.",
|
||||
"ca": "Les extensions activades són codi de confiança amb accés complet a aquesta sessió del Canvas."
|
||||
},
|
||||
"SETTINGS$CANVAS_EXTENSIONS_ADD_MODAL_INTRO": {
|
||||
"en": "Install from a Git source or a path on the active Agent Server. The extension will be installed disabled.",
|
||||
"ja": "Git ソースまたはアクティブな Agent Server 上のパスからインストールします。拡張機能は無効な状態でインストールされます。",
|
||||
"zh-CN": "从 Git 来源或活动 Agent Server 上的路径安装。扩展安装后将处于禁用状态。",
|
||||
"zh-TW": "從 Git 來源或作用中 Agent Server 上的路徑安裝。擴充功能安裝後將處於停用狀態。",
|
||||
"ko-KR": "Git 소스 또는 활성 Agent Server의 경로에서 설치합니다. 확장 기능은 비활성화된 상태로 설치됩니다.",
|
||||
"no": "Installer fra en Git-kilde eller en sti på den aktive Agent Server. Utvidelsen installeres deaktivert.",
|
||||
"ar": "ثبّت من مصدر Git أو مسار على Agent Server النشط. سيتم تثبيت الامتداد في وضع التعطيل.",
|
||||
"de": "Installieren Sie aus einer Git-Quelle oder von einem Pfad auf dem aktiven Agent Server. Die Erweiterung wird deaktiviert installiert.",
|
||||
"fr": "Installez depuis une source Git ou un chemin sur l’Agent Server actif. L’extension sera installée désactivée.",
|
||||
"it": "Installa da una sorgente Git o da un percorso sull'Agent Server attivo. L'estensione verrà installata disattivata.",
|
||||
"pt": "Instale de uma origem Git ou de um caminho no Agent Server ativo. A extensão será instalada desativada.",
|
||||
"es": "Instala desde una fuente Git o una ruta en el Agent Server activo. La extensión se instalará desactivada.",
|
||||
"tr": "Bir Git kaynağından veya etkin Agent Server üzerindeki bir yoldan kurun. Uzantı devre dışı olarak kurulacaktır.",
|
||||
"uk": "Установіть із джерела Git або шляху на активному Agent Server. Розширення буде встановлено вимкненим.",
|
||||
"ca": "Instal·la des d'una font Git o un camí de l'Agent Server actiu. L'extensió s'instal·larà desactivada."
|
||||
},
|
||||
"SETTINGS$CANVAS_EXTENSIONS_INSTALL_SUCCESS": {
|
||||
"en": "Extension installed. Review it here, then enable it when you are ready.",
|
||||
"ja": "拡張機能をインストールしました。ここで内容を確認し、準備ができたら有効にしてください。",
|
||||
"zh-CN": "扩展已安装。请在此处查看,并在准备好后启用。",
|
||||
"zh-TW": "擴充功能已安裝。請在此處檢視,並在準備好後啟用。",
|
||||
"ko-KR": "확장 기능이 설치되었습니다. 여기에서 검토한 후 준비가 되면 활성화하세요.",
|
||||
"no": "Utvidelsen er installert. Se gjennom den her, og aktiver den når du er klar.",
|
||||
"ar": "تم تثبيت الامتداد. راجعه هنا، ثم فعّله عندما تكون مستعدًا.",
|
||||
"de": "Erweiterung installiert. Prüfen Sie sie hier und aktivieren Sie sie, wenn Sie bereit sind.",
|
||||
"fr": "Extension installée. Vérifiez-la ici, puis activez-la lorsque vous êtes prêt.",
|
||||
"it": "Estensione installata. Esaminala qui, quindi abilitala quando sei pronto.",
|
||||
"pt": "Extensão instalada. Revise-a aqui e ative-a quando estiver pronto.",
|
||||
"es": "Extensión instalada. Revísala aquí y habilítala cuando estés listo.",
|
||||
"tr": "Uzantı kuruldu. Burada inceleyin ve hazır olduğunuzda etkinleştirin.",
|
||||
"uk": "Розширення встановлено. Перегляньте його тут і ввімкніть, коли будете готові.",
|
||||
"ca": "Extensió instal·lada. Revisa-la aquí i activa-la quan estiguis a punt."
|
||||
},
|
||||
"SETTINGS$CANVAS_EXTENSIONS_UNINSTALL_SUCCESS": {
|
||||
"en": "Extension uninstalled.",
|
||||
"ja": "拡張機能をアンインストールしました。",
|
||||
"zh-CN": "扩展已卸载。",
|
||||
"zh-TW": "擴充功能已解除安裝。",
|
||||
"ko-KR": "확장 기능이 제거되었습니다.",
|
||||
"no": "Utvidelsen er avinstallert.",
|
||||
"ar": "تمت إزالة تثبيت الامتداد.",
|
||||
"de": "Erweiterung deinstalliert.",
|
||||
"fr": "Extension désinstallée.",
|
||||
"it": "Estensione disinstallata.",
|
||||
"pt": "Extensão desinstalada.",
|
||||
"es": "Extensión desinstalada.",
|
||||
"tr": "Uzantı kaldırıldı.",
|
||||
"uk": "Розширення видалено.",
|
||||
"ca": "Extensió desinstal·lada."
|
||||
},
|
||||
"SETTINGS$CANVAS_EXTENSIONS_ENABLE_CONFIRM": {
|
||||
"en": "Enabled extensions are trusted code with full access to this Canvas session. Enable {{name}}?",
|
||||
"ja": "有効な拡張機能は、この Canvas セッションに完全にアクセスできる信頼済みコードです。{{name}} を有効にしますか?",
|
||||
"zh-CN": "已启用的扩展是受信任代码,可完全访问此 Canvas 会话。要启用 {{name}} 吗?",
|
||||
"zh-TW": "已啟用的擴充功能是受信任程式碼,可完整存取此 Canvas 工作階段。要啟用 {{name}} 嗎?",
|
||||
"ko-KR": "활성화된 확장 기능은 이 Canvas 세션에 대한 전체 액세스 권한이 있는 신뢰할 수 있는 코드입니다. {{name}}을(를) 활성화하시겠습니까?",
|
||||
"no": "Aktiverte utvidelser er klarert kode med full tilgang til denne Canvas-økten. Vil du aktivere {{name}}?",
|
||||
"ar": "الامتدادات المفعّلة هي تعليمات برمجية موثوقة تتمتع بوصول كامل إلى جلسة Canvas هذه. هل تريد تفعيل {{name}}؟",
|
||||
"de": "Aktivierte Erweiterungen sind vertrauenswürdiger Code mit vollständigem Zugriff auf diese Canvas-Sitzung. {{name}} aktivieren?",
|
||||
"fr": "Les extensions activées sont du code de confiance avec un accès complet à cette session Canvas. Activer {{name}} ?",
|
||||
"it": "Le estensioni abilitate sono codice attendibile con accesso completo a questa sessione Canvas. Abilitare {{name}}?",
|
||||
"pt": "As extensões ativadas são código confiável com acesso total a esta sessão do Canvas. Ativar {{name}}?",
|
||||
"es": "Las extensiones habilitadas son código de confianza con acceso completo a esta sesión de Canvas. ¿Habilitar {{name}}?",
|
||||
"tr": "Etkin uzantılar, bu Canvas oturumuna tam erişimi olan güvenilir kodlardır. {{name}} etkinleştirilsin mi?",
|
||||
"uk": "Увімкнені розширення є довіреним кодом із повним доступом до цього сеансу Canvas. Увімкнути {{name}}?",
|
||||
"ca": "Les extensions activades són codi de confiança amb accés complet a aquesta sessió del Canvas. Vols activar {{name}}?"
|
||||
},
|
||||
"SETTINGS$CANVAS_EXTENSIONS_UNINSTALL_CONFIRM": {
|
||||
"en": "Uninstall {{name}} from this backend?",
|
||||
"ja": "このバックエンドから {{name}} をアンインストールしますか?",
|
||||
"zh-CN": "要从此后端卸载 {{name}} 吗?",
|
||||
"zh-TW": "要從此後端解除安裝 {{name}} 嗎?",
|
||||
"ko-KR": "이 백엔드에서 {{name}}을(를) 제거하시겠습니까?",
|
||||
"no": "Vil du avinstallere {{name}} fra denne backenden?",
|
||||
"ar": "هل تريد إزالة تثبيت {{name}} من هذه الواجهة الخلفية؟",
|
||||
"de": "{{name}} von diesem Backend deinstallieren?",
|
||||
"fr": "Désinstaller {{name}} de ce backend ?",
|
||||
"it": "Disinstallare {{name}} da questo backend?",
|
||||
"pt": "Desinstalar {{name}} deste backend?",
|
||||
"es": "¿Desinstalar {{name}} de este backend?",
|
||||
"tr": "{{name}} bu arka uçtan kaldırılsın mı?",
|
||||
"uk": "Видалити {{name}} із цього бекенда?",
|
||||
"ca": "Vols desinstal·lar {{name}} d'aquest backend?"
|
||||
},
|
||||
"SETTINGS$CANVAS_EXTENSIONS_PAGES": {
|
||||
"en": "Pages",
|
||||
"ja": "ページ",
|
||||
"zh-CN": "页面",
|
||||
"zh-TW": "頁面",
|
||||
"ko-KR": "페이지",
|
||||
"no": "Sider",
|
||||
"ar": "الصفحات",
|
||||
"de": "Seiten",
|
||||
"fr": "Pages",
|
||||
"it": "Pagine",
|
||||
"pt": "Páginas",
|
||||
"es": "Páginas",
|
||||
"tr": "Sayfalar",
|
||||
"uk": "Сторінки",
|
||||
"ca": "Pàgines"
|
||||
},
|
||||
"SETTINGS$CANVAS_EXTENSIONS_PAGE_UNAVAILABLE": {
|
||||
"en": "This extension is disabled, missing, or does not provide this page.",
|
||||
"ja": "この拡張機能は無効、未インストール、またはこのページを提供していません。",
|
||||
"zh-CN": "此扩展已禁用、缺失或未提供此页面。",
|
||||
"zh-TW": "此擴充功能已停用、遺失或未提供此頁面。",
|
||||
"ko-KR": "이 확장 기능은 비활성화되었거나 없거나 이 페이지를 제공하지 않습니다.",
|
||||
"no": "Denne utvidelsen er deaktivert, mangler eller tilbyr ikke denne siden.",
|
||||
"ar": "هذا الامتداد معطّل أو مفقود أو لا يوفّر هذه الصفحة.",
|
||||
"de": "Diese Erweiterung ist deaktiviert, fehlt oder stellt diese Seite nicht bereit.",
|
||||
"fr": "Cette extension est désactivée, absente ou ne fournit pas cette page.",
|
||||
"it": "Questa estensione è disabilitata, mancante o non fornisce questa pagina.",
|
||||
"pt": "Esta extensão está desativada, ausente ou não fornece esta página.",
|
||||
"es": "Esta extensión está deshabilitada, falta o no proporciona esta página.",
|
||||
"tr": "Bu uzantı devre dışı, eksik veya bu sayfayı sağlamıyor.",
|
||||
"uk": "Це розширення вимкнене, відсутнє або не надає цю сторінку.",
|
||||
"ca": "Aquesta extensió està desactivada, no hi és o no proporciona aquesta pàgina."
|
||||
},
|
||||
"SETTINGS$SKILLS_PAGE_DESCRIPTION": {
|
||||
"en": "Discover skills to add to your workspace. Open a card for prompts, curl, and install flows. Search from the sidebar to filter the list. Enable or disable default skills. Disabled skills will not be loaded into agent context.",
|
||||
"ja": "ワークスペースに追加するスキルを見つけましょう。カードを開いて、プロンプト、curl、インストール手順を確認できます。サイドバーから検索してリストを絞り込めます。デフォルトスキルの有効化・無効化が可能です。無効化されたスキルはエージェントコンテキストに読み込まれません。",
|
||||
|
||||
@@ -39,6 +39,21 @@ export {
|
||||
type AgentServerUIStyleOverrides,
|
||||
type AgentServerUITheme,
|
||||
} from "../styles/agent-server-ui-style-scope";
|
||||
export {
|
||||
CANVAS_EXTENSION_HOST_API_VERSION,
|
||||
CANVAS_EXTENSION_MANIFEST_SCHEMA_VERSION,
|
||||
type CanvasExtensionAgentServerRequest,
|
||||
type CanvasExtensionContributions,
|
||||
type CanvasExtensionDispose,
|
||||
type CanvasExtensionHost,
|
||||
type CanvasExtensionManifest,
|
||||
type CanvasExtensionModule,
|
||||
type CanvasExtensionPageContribution,
|
||||
type CanvasExtensionPageMount,
|
||||
type CanvasExtensionPageMountContext,
|
||||
type InstallCanvasExtensionRequest,
|
||||
type InstalledCanvasExtensionInfo,
|
||||
} from "../types/canvas-extension";
|
||||
|
||||
// Telemetry exports
|
||||
export { useTelemetry, type UseTelemetryReturn } from "../hooks/use-telemetry";
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/* eslint-disable local/no-direct-agent-server-fetch --
|
||||
These fetches deliberately bypass the typed client to exercise the MSW
|
||||
network-interception layer that dev:mock relies on. */
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
CANVAS_EXTENSION_DEMO_SOURCE,
|
||||
resetCanvasExtensionsMockData,
|
||||
} from "#/mocks/canvas-extensions-handlers";
|
||||
|
||||
afterEach(() => {
|
||||
resetCanvasExtensionsMockData();
|
||||
});
|
||||
|
||||
async function installDemo(): Promise<Response> {
|
||||
return fetch("http://127.0.0.1:8000/api/canvas-extensions/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source: CANVAS_EXTENSION_DEMO_SOURCE }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("Canvas Extension MSW handlers", () => {
|
||||
it("installs the fixture disabled and then enables it explicitly", async () => {
|
||||
const installResponse = await installDemo();
|
||||
expect(installResponse.status).toBe(200);
|
||||
await expect(installResponse.json()).resolves.toMatchObject({
|
||||
name: "demo-page",
|
||||
enabled: false,
|
||||
source: CANVAS_EXTENSION_DEMO_SOURCE,
|
||||
});
|
||||
|
||||
const enableResponse = await fetch(
|
||||
"http://127.0.0.1:8000/api/canvas-extensions/installed/demo-page",
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
},
|
||||
);
|
||||
await expect(enableResponse.json()).resolves.toEqual({
|
||||
name: "demo-page",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const listResponse = await fetch(
|
||||
"http://127.0.0.1:8000/api/canvas-extensions/installed",
|
||||
);
|
||||
await expect(listResponse.json()).resolves.toMatchObject({
|
||||
canvas_extensions: [{ name: "demo-page", enabled: true }],
|
||||
});
|
||||
});
|
||||
|
||||
it("serves the fixture as JavaScript text", async () => {
|
||||
await installDemo();
|
||||
|
||||
const response = await fetch(
|
||||
"http://127.0.0.1:8000/api/canvas-extensions/installed/demo-page/bundle",
|
||||
);
|
||||
|
||||
expect(response.headers.get("content-type")).toContain(
|
||||
"application/javascript",
|
||||
);
|
||||
await expect(response.text()).resolves.toContain(
|
||||
"export function activate",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects arbitrary sources in mock mode", async () => {
|
||||
const response = await fetch(
|
||||
"http://127.0.0.1:8000/api/canvas-extensions/install",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source: "github:example/unknown" }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
detail: `Mock mode only installs '${CANVAS_EXTENSION_DEMO_SOURCE}'.`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import type {
|
||||
CanvasExtensionManifest,
|
||||
InstallCanvasExtensionRequest,
|
||||
InstalledCanvasExtensionInfo,
|
||||
} from "#/types/canvas-extension";
|
||||
import demoManifestSource from "../../tests/fixtures/canvas-extensions/demo-page/canvas-extension.json?raw";
|
||||
// eslint-disable-next-line import-x/extensions -- Vite requires the real filename before ?raw.
|
||||
import demoBundle from "../../tests/fixtures/canvas-extensions/demo-page/extension.js?raw";
|
||||
|
||||
export const CANVAS_EXTENSION_DEMO_SOURCE =
|
||||
"tests/fixtures/canvas-extensions/demo-page";
|
||||
|
||||
const demoManifest = JSON.parse(demoManifestSource) as CanvasExtensionManifest;
|
||||
|
||||
const MOCK_STORAGE_KEY = "openhands-canvas-extension-dev-installation";
|
||||
|
||||
function readPersistedExtension(): InstalledCanvasExtensionInfo | null {
|
||||
try {
|
||||
const value = globalThis.sessionStorage?.getItem(MOCK_STORAGE_KEY);
|
||||
return value ? (JSON.parse(value) as InstalledCanvasExtensionInfo) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let installedExtension = readPersistedExtension();
|
||||
|
||||
function persistInstalledExtension(
|
||||
extension: InstalledCanvasExtensionInfo | null,
|
||||
): void {
|
||||
installedExtension = extension;
|
||||
try {
|
||||
if (extension) {
|
||||
globalThis.sessionStorage?.setItem(
|
||||
MOCK_STORAGE_KEY,
|
||||
JSON.stringify(extension),
|
||||
);
|
||||
} else {
|
||||
globalThis.sessionStorage?.removeItem(MOCK_STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
// The module variable still provides an in-memory fallback.
|
||||
}
|
||||
}
|
||||
|
||||
function isDemoSource(source: string): boolean {
|
||||
return source.replaceAll("\\", "/").endsWith(CANVAS_EXTENSION_DEMO_SOURCE);
|
||||
}
|
||||
|
||||
function getRequestedName(
|
||||
value: string | readonly string[] | undefined,
|
||||
): string {
|
||||
if (!value) return "";
|
||||
return decodeURIComponent(Array.isArray(value) ? value[0] : value);
|
||||
}
|
||||
|
||||
function extensionNotFound(name: string) {
|
||||
return HttpResponse.json(
|
||||
{ detail: `Canvas extension '${name}' is not installed.` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
function getInstalledExtension(
|
||||
name: string,
|
||||
): InstalledCanvasExtensionInfo | null {
|
||||
if (installedExtension?.name !== name) return null;
|
||||
return installedExtension;
|
||||
}
|
||||
|
||||
export function resetCanvasExtensionsMockData(): void {
|
||||
persistInstalledExtension(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-development implementation of the Canvas Extension API contract.
|
||||
* It intentionally supports only the checked-in demo fixture. Production and
|
||||
* ordinary dev stacks continue to use the active Agent Server exclusively.
|
||||
*/
|
||||
export const CANVAS_EXTENSIONS_HANDLERS = [
|
||||
http.get("*/api/canvas-extensions/installed", () =>
|
||||
HttpResponse.json({
|
||||
canvas_extensions: installedExtension ? [installedExtension] : [],
|
||||
}),
|
||||
),
|
||||
|
||||
http.post("*/api/canvas-extensions/install", async ({ request }) => {
|
||||
const body = (await request.json()) as InstallCanvasExtensionRequest;
|
||||
if (!body.source || !isDemoSource(body.source)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: `Mock mode only installs '${CANVAS_EXTENSION_DEMO_SOURCE}'.`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (installedExtension && !body.force) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: `Canvas extension '${demoManifest.name}' is already installed.`,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
persistInstalledExtension({
|
||||
name: demoManifest.name,
|
||||
version: demoManifest.version,
|
||||
description: demoManifest.description,
|
||||
enabled: false,
|
||||
source: body.source,
|
||||
resolved_ref: body.ref || "mock-working-tree",
|
||||
repo_path: body.repo_path || null,
|
||||
installed_at: new Date().toISOString(),
|
||||
install_path: `/mock/canvas-extensions/${demoManifest.name}`,
|
||||
// The real backend only returns `manifest` once OSS-10048 lands; the
|
||||
// mock previews that shape so the demo fixture's pages work in dev mode.
|
||||
manifest: demoManifest,
|
||||
});
|
||||
|
||||
return HttpResponse.json(installedExtension);
|
||||
}),
|
||||
|
||||
http.get("*/api/canvas-extensions/installed/:name/bundle", ({ params }) => {
|
||||
const name = getRequestedName(params.name);
|
||||
if (!getInstalledExtension(name)) return extensionNotFound(name);
|
||||
return new HttpResponse(demoBundle, {
|
||||
headers: {
|
||||
"Content-Type": "application/javascript; charset=utf-8",
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.get("*/api/canvas-extensions/installed/:name", ({ params }) => {
|
||||
const name = getRequestedName(params.name);
|
||||
const extension = getInstalledExtension(name);
|
||||
return extension ? HttpResponse.json(extension) : extensionNotFound(name);
|
||||
}),
|
||||
|
||||
http.patch(
|
||||
"*/api/canvas-extensions/installed/:name",
|
||||
async ({ params, request }) => {
|
||||
const name = getRequestedName(params.name);
|
||||
const extension = getInstalledExtension(name);
|
||||
if (!extension) return extensionNotFound(name);
|
||||
|
||||
const body = (await request.json()) as { enabled?: unknown };
|
||||
if (typeof body.enabled !== "boolean") {
|
||||
return HttpResponse.json(
|
||||
{ detail: "'enabled' must be a boolean." },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
persistInstalledExtension({ ...extension, enabled: body.enabled });
|
||||
return HttpResponse.json({ name, enabled: body.enabled });
|
||||
},
|
||||
),
|
||||
|
||||
http.delete("*/api/canvas-extensions/installed/:name", ({ params }) => {
|
||||
const name = getRequestedName(params.name);
|
||||
if (!getInstalledExtension(name)) return extensionNotFound(name);
|
||||
persistInstalledExtension(null);
|
||||
return HttpResponse.json({ message: "Canvas extension uninstalled." });
|
||||
}),
|
||||
];
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
WORKSPACES_HANDLERS,
|
||||
resetMockWorkspaces,
|
||||
} from "./workspaces-handlers";
|
||||
import {
|
||||
CANVAS_EXTENSIONS_HANDLERS,
|
||||
resetCanvasExtensionsMockData,
|
||||
} from "./canvas-extensions-handlers";
|
||||
|
||||
export const handlers = [
|
||||
...FILE_SERVICE_HANDLERS,
|
||||
@@ -38,6 +42,7 @@ export const handlers = [
|
||||
...AUTOMATION_HANDLERS,
|
||||
...MCP_HANDLERS,
|
||||
...WORKSPACES_HANDLERS,
|
||||
...CANVAS_EXTENSIONS_HANDLERS,
|
||||
];
|
||||
|
||||
export {
|
||||
@@ -45,6 +50,7 @@ export {
|
||||
resetTestHandlersMockSettings,
|
||||
resetAutomationMockData,
|
||||
resetMockWorkspaces,
|
||||
resetCanvasExtensionsMockData,
|
||||
};
|
||||
|
||||
export {
|
||||
|
||||
@@ -18,6 +18,8 @@ export default [
|
||||
route("customize", "routes/extensions-hub.tsx"),
|
||||
route("skills", "routes/skills-settings.tsx"),
|
||||
route("plugins", "routes/skills-plugins.tsx"),
|
||||
route("extensions", "routes/canvas-extensions.tsx"),
|
||||
route("extensions/:extensionName/*", "routes/canvas-extension-page.tsx"),
|
||||
route("mcp", "routes/mcp.tsx"),
|
||||
route("settings", "routes/settings.tsx", [
|
||||
index("routes/settings-index.tsx"),
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { useCanvasExtensionsRuntime } from "#/components/features/canvas-extensions/canvas-extensions-runtime";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export default function CanvasExtensionPage() {
|
||||
const { t } = useTranslation("openhands");
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const extensionName = params.extensionName ?? "";
|
||||
const routePath = params["*"] ?? "";
|
||||
const { pages, activating, errors } = useCanvasExtensionsRuntime();
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [mountError, setMountError] = React.useState<string | null>(null);
|
||||
|
||||
const page = React.useMemo(
|
||||
() =>
|
||||
pages
|
||||
.filter((candidate) => candidate.extension.name === extensionName)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.contribution.path.length - left.contribution.path.length,
|
||||
)
|
||||
.find(
|
||||
(candidate) =>
|
||||
routePath === candidate.contribution.path ||
|
||||
routePath.startsWith(`${candidate.contribution.path}/`),
|
||||
),
|
||||
[extensionName, pages, routePath],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !page) return undefined;
|
||||
let disposed = false;
|
||||
let disposeMount: (() => void) | undefined;
|
||||
setMountError(null);
|
||||
container.replaceChildren();
|
||||
|
||||
const remainder = routePath
|
||||
.slice(page.contribution.path.length)
|
||||
.replace(/^\/+/, "");
|
||||
Promise.resolve()
|
||||
.then(() =>
|
||||
page.mount({
|
||||
container,
|
||||
path: remainder,
|
||||
navigate: (path) => navigate(path),
|
||||
}),
|
||||
)
|
||||
.then((dispose) => {
|
||||
if (typeof dispose !== "function") return;
|
||||
if (disposed) dispose();
|
||||
else disposeMount = dispose;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!disposed) {
|
||||
setMountError(
|
||||
error instanceof Error ? error.message : "Extension page failed.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
try {
|
||||
disposeMount?.();
|
||||
} catch (error) {
|
||||
console.error("Canvas Extension page cleanup failed", error);
|
||||
}
|
||||
container.replaceChildren();
|
||||
};
|
||||
}, [navigate, page, routePath]);
|
||||
|
||||
if (activating && !page) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const error = mountError ?? errors.get(extensionName);
|
||||
if (error || !page) {
|
||||
return (
|
||||
<main className="flex h-full items-center justify-center p-8">
|
||||
<div className="max-w-lg rounded-xl border border-[var(--oh-border)] bg-base-secondary p-6 text-center">
|
||||
<h1 className="text-lg font-semibold text-white">
|
||||
{t(I18nKey.SETUP$UNAVAILABLE_TITLE)}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-tertiary-light">
|
||||
{error || t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_PAGE_UNAVAILABLE)}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
aria-label={page.contribution.title}
|
||||
className="h-full min-h-0 overflow-auto"
|
||||
>
|
||||
<div ref={containerRef} className="min-h-full" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExtensionsNavigation } from "#/components/features/skills/extensions-navigation";
|
||||
import { AddCanvasExtensionModal } from "#/components/features/canvas-extensions/add-canvas-extension-modal";
|
||||
import { CanvasExtensionCard } from "#/components/features/canvas-extensions/canvas-extension-card";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal";
|
||||
import {
|
||||
CanvasExtensionsUnsupportedError,
|
||||
isCanvasExtensionsUnsupportedError,
|
||||
} from "#/api/canvas-extensions-service";
|
||||
import { isNoBackend } from "#/api/backend-registry/active-store";
|
||||
import { useActiveBackend } from "#/contexts/active-backend-context";
|
||||
import { useCanvasExtensions } from "#/hooks/query/use-canvas-extensions";
|
||||
import {
|
||||
useSetCanvasExtensionEnabled,
|
||||
useUninstallCanvasExtension,
|
||||
} from "#/hooks/mutation/use-manage-canvas-extensions";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { settingsLikeMainScrollClassName } from "#/utils/settings-like-page-layout-classes";
|
||||
import {
|
||||
extensionModuleCardGridClassName,
|
||||
extensionModuleCardGridContainerClassName,
|
||||
extensionModuleEmptyStateClassName,
|
||||
} from "#/utils/extension-module-card-classes";
|
||||
import type { InstalledCanvasExtensionInfo } from "#/types/canvas-extension";
|
||||
|
||||
type PendingAction =
|
||||
| { type: "enable"; extension: InstalledCanvasExtensionInfo }
|
||||
| { type: "uninstall"; extension: InstalledCanvasExtensionInfo };
|
||||
|
||||
export default function CanvasExtensionsScreen() {
|
||||
const { t } = useTranslation("openhands");
|
||||
const { backend } = useActiveBackend();
|
||||
const [showAddModal, setShowAddModal] = React.useState(false);
|
||||
const [pendingAction, setPendingAction] =
|
||||
React.useState<PendingAction | null>(null);
|
||||
const query = useCanvasExtensions();
|
||||
const setEnabled = useSetCanvasExtensionEnabled();
|
||||
const uninstall = useUninstallCanvasExtension();
|
||||
|
||||
const backendCanSupportExtensions =
|
||||
!isNoBackend(backend) && backend.kind === "local";
|
||||
const unsupported =
|
||||
!backendCanSupportExtensions ||
|
||||
isCanvasExtensionsUnsupportedError(query.error);
|
||||
const isBusy = setEnabled.isPending || uninstall.isPending;
|
||||
|
||||
const confirmAction = () => {
|
||||
if (!pendingAction) return;
|
||||
if (pendingAction.type === "enable") {
|
||||
setEnabled.mutate(
|
||||
{ name: pendingAction.extension.name, enabled: true },
|
||||
{ onSuccess: () => setPendingAction(null) },
|
||||
);
|
||||
} else {
|
||||
uninstall.mutate(pendingAction.extension.name, {
|
||||
onSuccess: () => setPendingAction(null),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const pendingDisplayName = pendingAction
|
||||
? pendingAction.extension.manifest?.display_name ||
|
||||
pendingAction.extension.name
|
||||
: "";
|
||||
const confirmationText =
|
||||
pendingAction?.type === "enable"
|
||||
? t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_ENABLE_CONFIRM, {
|
||||
name: pendingDisplayName,
|
||||
})
|
||||
: pendingAction?.type === "uninstall"
|
||||
? t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_UNINSTALL_CONFIRM, {
|
||||
name: pendingDisplayName,
|
||||
})
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="canvas-extensions-screen"
|
||||
className="flex h-full gap-4 md:gap-6 md:pl-8 lg:gap-10 lg:pl-10"
|
||||
>
|
||||
<ExtensionsNavigation />
|
||||
<main className={cn(settingsLikeMainScrollClassName, "h-full")}>
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-[800px] flex-col gap-6">
|
||||
<div className="flex min-w-0 items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h2 className="text-xl font-semibold leading-6 text-foreground">
|
||||
{t(I18nKey.NAV$EXTENSIONS)}
|
||||
</h2>
|
||||
<p className="max-w-2xl text-sm text-tertiary-light">
|
||||
{t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_PAGE_DESCRIPTION)}
|
||||
</p>
|
||||
</div>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
testId="canvas-extensions-add-button"
|
||||
isDisabled={
|
||||
!backendCanSupportExtensions || unsupported || query.isLoading
|
||||
}
|
||||
className="flex-shrink-0 whitespace-nowrap"
|
||||
onClick={() => setShowAddModal(true)}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_ADD_BUTTON)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-amber-400/30 bg-amber-400/5 px-4 py-3 text-sm text-amber-100">
|
||||
{t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_TRUST_NOTICE)}
|
||||
</div>
|
||||
|
||||
{unsupported ? (
|
||||
<div className={extensionModuleEmptyStateClassName}>
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
{t(I18nKey.SETUP$UNAVAILABLE_TITLE)}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-tertiary-light">
|
||||
{isCanvasExtensionsUnsupportedError(query.error)
|
||||
? query.error.message
|
||||
: backend.kind === "cloud"
|
||||
? new CanvasExtensionsUnsupportedError("cloud-backend")
|
||||
.message
|
||||
: new CanvasExtensionsUnsupportedError("no-backend")
|
||||
.message}
|
||||
</p>
|
||||
</div>
|
||||
) : query.isLoading ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
{[1, 2].map((index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-40 animate-pulse rounded-xl bg-base-secondary"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : query.isError ? (
|
||||
<div className={extensionModuleEmptyStateClassName}>
|
||||
<p className="text-sm text-tertiary-light">
|
||||
{query.error instanceof Error
|
||||
? query.error.message
|
||||
: t(I18nKey.ERROR$GENERIC)}
|
||||
</p>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="mt-4"
|
||||
onClick={() => void query.refetch()}
|
||||
>
|
||||
{t(I18nKey.AUTOMATIONS$ERROR_RETRY)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
) : query.data?.length ? (
|
||||
<div className={extensionModuleCardGridContainerClassName}>
|
||||
<div className={extensionModuleCardGridClassName}>
|
||||
{query.data.map((extension) => (
|
||||
<CanvasExtensionCard
|
||||
key={extension.name}
|
||||
extension={extension}
|
||||
isBusy={isBusy}
|
||||
onToggle={() => {
|
||||
if (extension.enabled) {
|
||||
setEnabled.mutate({
|
||||
name: extension.name,
|
||||
enabled: false,
|
||||
});
|
||||
} else {
|
||||
setPendingAction({ type: "enable", extension });
|
||||
}
|
||||
}}
|
||||
onUninstall={() =>
|
||||
setPendingAction({ type: "uninstall", extension })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={extensionModuleEmptyStateClassName}>
|
||||
<p className="text-sm text-tertiary-light">
|
||||
{t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_EMPTY)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{showAddModal ? (
|
||||
<AddCanvasExtensionModal onClose={() => setShowAddModal(false)} />
|
||||
) : null}
|
||||
{pendingAction ? (
|
||||
<ConfirmationModal
|
||||
text={confirmationText}
|
||||
onCancel={() => setPendingAction(null)}
|
||||
onConfirm={confirmAction}
|
||||
isConfirming={isBusy}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+38
-35
@@ -24,6 +24,7 @@ import { useAppTitle } from "#/hooks/use-app-title";
|
||||
import { ReactRouterNavigationProvider } from "./react-router-navigation-provider";
|
||||
import { OnboardingHost } from "#/components/features/onboarding";
|
||||
import { isOnboardingPreviewActive } from "#/components/features/onboarding/onboarding-preview";
|
||||
import { CanvasExtensionsRuntimeProvider } from "#/components/features/canvas-extensions/canvas-extensions-runtime";
|
||||
|
||||
const EnvironmentSwitchOverlay = React.lazy(
|
||||
() => import("#/components/features/backends/environment-switch-overlay"),
|
||||
@@ -110,44 +111,46 @@ export default function MainApp() {
|
||||
|
||||
return (
|
||||
<ReactRouterNavigationProvider>
|
||||
<SidebarMobileNavProvider>
|
||||
<div
|
||||
data-testid="root-layout"
|
||||
className="h-screen lg:min-w-5xl flex flex-col md:flex-row bg-base overflow-hidden p-0"
|
||||
>
|
||||
<title>{appTitle}</title>
|
||||
<Sidebar />
|
||||
<CanvasExtensionsRuntimeProvider>
|
||||
<SidebarMobileNavProvider>
|
||||
<div
|
||||
data-testid="root-layout"
|
||||
className="h-screen lg:min-w-5xl flex flex-col md:flex-row bg-base overflow-hidden p-0"
|
||||
>
|
||||
<title>{appTitle}</title>
|
||||
<Sidebar />
|
||||
|
||||
<div className="flex min-h-0 flex-col w-full min-w-0 h-full gap-3">
|
||||
{!hideMobileSidebarMenuBar ? <SidebarMobileMenuBar /> : null}
|
||||
{config.data &&
|
||||
(config.data.maintenance_start_time ||
|
||||
(config.data.faulty_models &&
|
||||
config.data.faulty_models.length > 0) ||
|
||||
config.data.error_message) && (
|
||||
<React.Suspense fallback={null}>
|
||||
<AlertBanner
|
||||
maintenanceStartTime={config.data.maintenance_start_time}
|
||||
faultyModels={config.data.faulty_models}
|
||||
errorMessage={config.data.error_message}
|
||||
updatedAt={config.data.updated_at}
|
||||
/>
|
||||
</React.Suspense>
|
||||
)}
|
||||
<div
|
||||
id="root-outlet"
|
||||
className="relative flex-1 overflow-auto px-0 custom-scrollbar"
|
||||
>
|
||||
<Outlet />
|
||||
<div className="flex min-h-0 flex-col w-full min-w-0 h-full gap-3">
|
||||
{!hideMobileSidebarMenuBar ? <SidebarMobileMenuBar /> : null}
|
||||
{config.data &&
|
||||
(config.data.maintenance_start_time ||
|
||||
(config.data.faulty_models &&
|
||||
config.data.faulty_models.length > 0) ||
|
||||
config.data.error_message) && (
|
||||
<React.Suspense fallback={null}>
|
||||
<AlertBanner
|
||||
maintenanceStartTime={config.data.maintenance_start_time}
|
||||
faultyModels={config.data.faulty_models}
|
||||
errorMessage={config.data.error_message}
|
||||
updatedAt={config.data.updated_at}
|
||||
/>
|
||||
</React.Suspense>
|
||||
)}
|
||||
<div
|
||||
id="root-outlet"
|
||||
className="relative flex-1 overflow-auto px-0 custom-scrollbar"
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<React.Suspense fallback={null}>
|
||||
<EnvironmentSwitchOverlay />
|
||||
<CommandMenu />
|
||||
</React.Suspense>
|
||||
{showOnboardingPreview ? <OnboardingHost /> : null}
|
||||
</SidebarMobileNavProvider>
|
||||
<React.Suspense fallback={null}>
|
||||
<EnvironmentSwitchOverlay />
|
||||
<CommandMenu />
|
||||
</React.Suspense>
|
||||
{showOnboardingPreview ? <OnboardingHost /> : null}
|
||||
</SidebarMobileNavProvider>
|
||||
</CanvasExtensionsRuntimeProvider>
|
||||
</ReactRouterNavigationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
export const CANVAS_EXTENSION_MANIFEST_SCHEMA_VERSION = 1 as const;
|
||||
export const CANVAS_EXTENSION_HOST_API_VERSION = "1" as const;
|
||||
|
||||
export interface CanvasExtensionPageContribution {
|
||||
id: string;
|
||||
title: string;
|
||||
path: string;
|
||||
nav_label?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface CanvasExtensionContributions {
|
||||
pages?: CanvasExtensionPageContribution[] | null;
|
||||
}
|
||||
|
||||
/** Parsed contents of `canvas-extension.json`. */
|
||||
export interface CanvasExtensionManifest {
|
||||
schema_version: typeof CANVAS_EXTENSION_MANIFEST_SCHEMA_VERSION;
|
||||
name: string;
|
||||
display_name?: string | null;
|
||||
version: string;
|
||||
description?: string | null;
|
||||
entrypoint: string;
|
||||
contributes?: CanvasExtensionContributions | null;
|
||||
}
|
||||
|
||||
export interface InstalledCanvasExtensionInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string | null;
|
||||
enabled: boolean;
|
||||
source: string;
|
||||
requested_ref?: string | null;
|
||||
resolved_ref?: string | null;
|
||||
repo_path?: string | null;
|
||||
installed_at: string;
|
||||
install_path: string;
|
||||
/** Absent until the backend exposes page contributions (OSS-10048). */
|
||||
manifest?: CanvasExtensionManifest | null;
|
||||
}
|
||||
|
||||
export interface InstallCanvasExtensionRequest {
|
||||
source: string;
|
||||
ref?: string | null;
|
||||
repo_path?: string | null;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export type CanvasExtensionDispose = () => void;
|
||||
|
||||
export interface CanvasExtensionPageMountContext {
|
||||
container: HTMLElement;
|
||||
/** Remainder of the route below the page contribution's declared path. */
|
||||
path: string;
|
||||
navigate: (path: string) => void;
|
||||
}
|
||||
|
||||
export type CanvasExtensionPageMount = (
|
||||
context: CanvasExtensionPageMountContext,
|
||||
) => void | CanvasExtensionDispose | Promise<void | CanvasExtensionDispose>;
|
||||
|
||||
export interface CanvasExtensionAgentServerRequest {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
path: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CanvasExtensionHost {
|
||||
readonly apiVersion: typeof CANVAS_EXTENSION_HOST_API_VERSION;
|
||||
readonly extension: Readonly<{
|
||||
name: string;
|
||||
version: string;
|
||||
resolvedRef: string | null;
|
||||
}>;
|
||||
readonly backend: Readonly<{
|
||||
id: string;
|
||||
kind: "local" | "cloud";
|
||||
orgId: string | null;
|
||||
}>;
|
||||
registerPage: (
|
||||
contributionId: string,
|
||||
mount: CanvasExtensionPageMount,
|
||||
) => CanvasExtensionDispose;
|
||||
navigate: (path: string) => void;
|
||||
agentServer: {
|
||||
request: <T = unknown>(
|
||||
request: CanvasExtensionAgentServerRequest,
|
||||
) => Promise<T>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CanvasExtensionModule {
|
||||
activate: (
|
||||
host: CanvasExtensionHost,
|
||||
) => void | CanvasExtensionDispose | Promise<void | CanvasExtensionDispose>;
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
const SETTINGS_PREFIX = "/settings";
|
||||
const CUSTOMIZE_HUB = "/customize";
|
||||
const EXTENSIONS_DETAIL_PATHS = ["/skills", "/mcp", "/plugins"] as const;
|
||||
// Exact match only: /extensions/:name/* routes are extension pages, which are
|
||||
// rail-level destinations rather than Customize details.
|
||||
const CANVAS_EXTENSIONS_MANAGEMENT_PATH = "/extensions";
|
||||
|
||||
export type MobileTopBarMode = "menu" | "back";
|
||||
|
||||
@@ -33,6 +36,7 @@ export function getMobileTopBarState(pathname: string): MobileTopBarState {
|
||||
}
|
||||
|
||||
if (
|
||||
pathname === CANVAS_EXTENSIONS_MANAGEMENT_PATH ||
|
||||
EXTENSIONS_DETAIL_PATHS.some(
|
||||
(path) => pathname === path || pathname.startsWith(`${path}/`),
|
||||
)
|
||||
@@ -50,6 +54,7 @@ export function getMobileTopBarState(pathname: string): MobileTopBarState {
|
||||
export function isExtensionsSectionPath(pathname: string): boolean {
|
||||
return (
|
||||
pathname === CUSTOMIZE_HUB ||
|
||||
pathname === CANVAS_EXTENSIONS_MANAGEMENT_PATH ||
|
||||
EXTENSIONS_DETAIL_PATHS.some(
|
||||
(path) => pathname === path || pathname.startsWith(`${path}/`),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Demo page Canvas Extension
|
||||
|
||||
This fixture is a standalone, dependency-free implementation of manifest
|
||||
schema 1 and host API 1. Its JavaScript file is already a self-contained browser
|
||||
ES module, so an Agent Server test implementation can install this directory and
|
||||
serve `extension.js` directly from the authenticated bundle endpoint.
|
||||
|
||||
For the browser-only installation flow that works before those Agent Server
|
||||
endpoints exist, see
|
||||
[`docs/CANVAS_EXTENSIONS_TESTING.md`](../../../../docs/CANVAS_EXTENSIONS_TESTING.md).
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "demo-page",
|
||||
"display_name": "Demo page",
|
||||
"version": "0.1.0",
|
||||
"description": "A dependency-free fixture for the Canvas Extension page ABI.",
|
||||
"entrypoint": "extension.js",
|
||||
"contributes": {
|
||||
"pages": [
|
||||
{
|
||||
"id": "hello",
|
||||
"title": "Hello from an extension",
|
||||
"path": "/hello",
|
||||
"nav_label": "Extension demo"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export function activate(host) {
|
||||
return host.registerPage("hello", ({ container, path }) => {
|
||||
const wrapper = document.createElement("section");
|
||||
wrapper.style.cssText =
|
||||
"min-height:100%;padding:2rem;color:var(--oh-text-primary,#fff);background:var(--oh-color-base,#111);";
|
||||
|
||||
const title = document.createElement("h1");
|
||||
title.textContent = "Hello from a Canvas Extension";
|
||||
title.style.cssText = "font-size:1.5rem;font-weight:600;";
|
||||
|
||||
const detail = document.createElement("p");
|
||||
detail.textContent = path
|
||||
? `Nested extension path: ${path}`
|
||||
: `Host API ${host.apiVersion} on backend ${host.backend.id}`;
|
||||
detail.style.cssText = "margin-top:0.75rem;opacity:0.75;";
|
||||
|
||||
wrapper.append(title, detail);
|
||||
container.append(wrapper);
|
||||
return () => wrapper.remove();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user