mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(ui): add portals page
This commit is contained in:
@@ -97,6 +97,14 @@ describe("sidebar entries", () => {
|
||||
expect(isSettingsNavigationRoute("apps")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps Portals as a first-class customizable workspace route", () => {
|
||||
expect(SIDEBAR_NAV_ROUTES).toContain("portals");
|
||||
expect(DEFAULT_SIDEBAR_ENTRIES).not.toContain("route:portals");
|
||||
expect(sidebarMoreRoutes(DEFAULT_SIDEBAR_ENTRIES)).toContain("portals");
|
||||
expect(settingsRoutes).not.toContain("portals");
|
||||
expect(isSettingsNavigationRoute("portals")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the plugin manager in customizable workspace routes", () => {
|
||||
expect(normalizeSidebarEntries(["route:plugins", "route:usage", "route:plugins"])).toEqual([
|
||||
"route:plugins",
|
||||
|
||||
@@ -94,6 +94,7 @@ describe("navigationIconForRoute", () => {
|
||||
custodian: "lobster",
|
||||
activity: "activity",
|
||||
apps: "layoutGrid",
|
||||
portals: "monitor",
|
||||
approvals: "badgeCheck",
|
||||
workboard: "kanban",
|
||||
dashboards: "layoutDashboard",
|
||||
@@ -216,6 +217,7 @@ describe("titleForRoute", () => {
|
||||
custodian: "OpenClaw",
|
||||
activity: "Activity",
|
||||
apps: "Apps",
|
||||
portals: "Portals",
|
||||
approvals: "Approvals",
|
||||
workboard: "Workboard",
|
||||
dashboards: "Dashboards",
|
||||
@@ -266,6 +268,7 @@ describe("subtitleForRoute", () => {
|
||||
custodian: "System setup and care.",
|
||||
activity: "Browser-local tool activity summaries.",
|
||||
apps: "Companion apps for phone, watch, desktop, and browser.",
|
||||
portals: "Live previews from agent-run applications.",
|
||||
approvals: "Recent exec, plugin, and system-agent approvals.",
|
||||
workboard: "Agent work queue and session handoff.",
|
||||
dashboards: "Sessions that open on their dashboard face.",
|
||||
@@ -311,6 +314,7 @@ describe("pathForRoute", () => {
|
||||
it("returns correct path without base", () => {
|
||||
expect(pathForRoute("chat")).toBe("/chat");
|
||||
expect(pathForRoute("apps")).toBe("/apps");
|
||||
expect(pathForRoute("portals")).toBe("/portals");
|
||||
expect(pathForRoute("dashboards")).toBe("/dashboards");
|
||||
expect(pathForRoute("custodian")).toBe("/custodian");
|
||||
expect(pathForRoute("connection")).toBe("/settings/connection");
|
||||
@@ -349,6 +353,7 @@ describe("routeIdFromPath", () => {
|
||||
expect(routeIdFromPath("/connection")).toBeNull();
|
||||
expect(routeIdFromPath("/activity")).toBe("activity");
|
||||
expect(routeIdFromPath("/apps")).toBe("apps");
|
||||
expect(routeIdFromPath("/portals")).toBe("portals");
|
||||
expect(routeIdFromPath("/dashboards")).toBe("dashboards");
|
||||
expect(routeIdFromPath("/sessions")).toBe("sessions");
|
||||
expect(routeIdFromPath("/debug")).toBe("debug");
|
||||
@@ -515,6 +520,18 @@ describe("plugin tabs route", () => {
|
||||
|
||||
describe("SIDEBAR_NAV_ROUTES", () => {
|
||||
it("all routes are unique", () => {
|
||||
expect(SIDEBAR_NAV_ROUTES).toEqual([
|
||||
"workboard",
|
||||
"dashboards",
|
||||
"usage",
|
||||
"cron",
|
||||
"tasks",
|
||||
"sessions",
|
||||
"activity",
|
||||
"plugins",
|
||||
"apps",
|
||||
"portals",
|
||||
]);
|
||||
expect(new Set(SIDEBAR_NAV_ROUTES).size).toBe(SIDEBAR_NAV_ROUTES.length);
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export const SIDEBAR_NAV_ROUTES = [
|
||||
"activity",
|
||||
"plugins",
|
||||
"apps",
|
||||
"portals",
|
||||
] as const satisfies readonly NavigationRouteId[];
|
||||
|
||||
// Routes presented as tabs of the Plugins hub. The sidebar highlights the
|
||||
@@ -222,6 +223,7 @@ const NAVIGATION_ICONS: NavigationItem = {
|
||||
agents: "bot",
|
||||
activity: "activity",
|
||||
apps: "layoutGrid",
|
||||
portals: "monitor",
|
||||
approvals: "badgeCheck",
|
||||
workboard: "kanban",
|
||||
worktrees: "folder",
|
||||
@@ -330,6 +332,7 @@ const NAVIGATION_COPY: Record<NavigationRouteId, { titleKey: string; subtitleKey
|
||||
agents: { titleKey: "tabs.agents", subtitleKey: "subtitles.agents" },
|
||||
activity: { titleKey: "tabs.activity", subtitleKey: "subtitles.activity" },
|
||||
apps: { titleKey: "tabs.apps", subtitleKey: "subtitles.apps" },
|
||||
portals: { titleKey: "tabs.portals", subtitleKey: "subtitles.portals" },
|
||||
approvals: { titleKey: "tabs.approvals", subtitleKey: "subtitles.approvals" },
|
||||
workboard: { titleKey: "tabs.workboard", subtitleKey: "subtitles.workboard" },
|
||||
worktrees: { titleKey: "tabs.worktrees", subtitleKey: "subtitles.worktrees" },
|
||||
|
||||
@@ -102,6 +102,11 @@ describe("Dynamic route startup bridge", () => {
|
||||
expect(routeIdFromPath("/settings/secrets")).toBe("secrets");
|
||||
});
|
||||
|
||||
it("registers the Portals workspace path", () => {
|
||||
expect(pathForRoute("portals")).toBe("/portals");
|
||||
expect(routeIdFromPath("/portals")).toBe("portals");
|
||||
});
|
||||
|
||||
it.each(DYNAMIC_STARTUP_CASES)(
|
||||
"loads the $label once while publishing its real location",
|
||||
async ({ routeId, location: initialLocation }) => {
|
||||
|
||||
@@ -27,6 +27,7 @@ const APP_ROUTE_DEFINITIONS = {
|
||||
"new-session": { path: "/new" },
|
||||
activity: { path: "/activity" },
|
||||
apps: { path: "/apps" },
|
||||
portals: { path: "/portals" },
|
||||
agents: { path: "/settings/agents", aliases: ["/agents"] },
|
||||
channels: { path: "/settings/channels", aliases: ["/channels"] },
|
||||
connection: { path: "/settings/connection" },
|
||||
|
||||
@@ -12,6 +12,7 @@ describe("application router registration", () => {
|
||||
|
||||
it("registers every route id exactly once", () => {
|
||||
const routeIds = router.routes.map((route) => route.id);
|
||||
expect(routeIds).toContain("portals");
|
||||
expect([...routeIds].toSorted()).toEqual([...APP_ROUTE_IDS].toSorted());
|
||||
});
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ import { page as modelSetupPage } from "./pages/model-setup/route.ts";
|
||||
import { page as newSessionPage } from "./pages/new-session/route.ts";
|
||||
import { page as pluginPage } from "./pages/plugin/route.ts";
|
||||
import { page as pluginsPage } from "./pages/plugins/route.ts";
|
||||
import { page as portalsPage } from "./pages/portals/route.ts";
|
||||
import { page as profilePage } from "./pages/profile/route.ts";
|
||||
import { page as secretsPage } from "./pages/secrets/route.ts";
|
||||
import { page as sessionsPage } from "./pages/sessions/route.ts";
|
||||
@@ -80,6 +81,7 @@ const APP_ROUTE_TREE = [
|
||||
activityPage,
|
||||
dashboardsPage,
|
||||
appsPage,
|
||||
portalsPage,
|
||||
agentsPage,
|
||||
approvalsPage,
|
||||
channelsPage,
|
||||
|
||||
@@ -2045,6 +2045,7 @@ export const en: TranslationMap = {
|
||||
agents: "Agents",
|
||||
activity: "Activity",
|
||||
apps: "Apps",
|
||||
portals: "Portals",
|
||||
approvals: "Approvals",
|
||||
workboard: "Workboard",
|
||||
worktrees: "Worktrees",
|
||||
@@ -2086,6 +2087,7 @@ export const en: TranslationMap = {
|
||||
agents: "Workspaces, tools, identities.",
|
||||
activity: "Browser-local tool activity summaries.",
|
||||
apps: "Companion apps for phone, watch, desktop, and browser.",
|
||||
portals: "Live previews from agent-run applications.",
|
||||
approvals: "Recent exec, plugin, and system-agent approvals.",
|
||||
workboard: "Agent work queue and session handoff.",
|
||||
worktrees: "Isolated agent task checkouts and recovery snapshots.",
|
||||
@@ -2126,6 +2128,21 @@ export const en: TranslationMap = {
|
||||
logs: "Live gateway logs.",
|
||||
plugin: "Plugin-provided panel.",
|
||||
},
|
||||
portalsPage: {
|
||||
listLabel: "Active portals",
|
||||
portLabel: "Port {port}",
|
||||
openNewTab: "Open in new tab",
|
||||
closePortal: "Close {title}",
|
||||
previewTitle: "{title} portal preview",
|
||||
loading: "Loading portals…",
|
||||
emptyHint: "Ask the agent to start a portal:",
|
||||
promptShow: "Show me in a portal.",
|
||||
promptStart: "Start the application in a portal.",
|
||||
promptMakeAvailable: "Make the server available in a portal.",
|
||||
unsupported: "This gateway does not support portals.",
|
||||
loadFailed: "Could not load portals: {error}",
|
||||
closeFailed: "Could not close the portal: {error}",
|
||||
},
|
||||
modelSetup: {
|
||||
heading: "Connect a verified AI model",
|
||||
intro:
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { PortalSummary } from "@openclaw/gateway-protocol";
|
||||
import { resolveGatewayHttpOrigin } from "../../components/sandbox-host.ts";
|
||||
|
||||
export function resolvePortalUrl(
|
||||
portal: Pick<PortalSummary, "listenPort" | "path" | "tokenQuery">,
|
||||
gatewayUrl: string,
|
||||
hostOrigin: string,
|
||||
): string {
|
||||
const url = new URL(resolveGatewayHttpOrigin(gatewayUrl, hostOrigin));
|
||||
url.port = String(portal.listenPort);
|
||||
url.pathname = portal.path ?? "/";
|
||||
url.search = portal.tokenQuery;
|
||||
return url.href;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import type { PortalListResult, PortalSummary } from "@openclaw/gateway-protocol";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient, GatewayEventFrame } from "../../api/gateway.ts";
|
||||
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
|
||||
import { resolvePortalUrl } from "./portal-url.ts";
|
||||
import "./portals-page.ts";
|
||||
|
||||
type PortalsPageTestElement = HTMLElement & {
|
||||
context: ApplicationContext;
|
||||
updateComplete: Promise<boolean>;
|
||||
};
|
||||
|
||||
const portal: PortalSummary = {
|
||||
id: "p3000",
|
||||
title: "Seeded app",
|
||||
port: 3000,
|
||||
listenPort: 43_123,
|
||||
tokenQuery: "openclaw_portal=secret-token",
|
||||
url: "http://127.0.0.1:43123/app?openclaw_portal=secret-token",
|
||||
publicUrl: "http://127.0.0.1:43123/app",
|
||||
path: "/app",
|
||||
description: "Use the seeded test account.",
|
||||
createdAtMs: 1_000,
|
||||
};
|
||||
|
||||
function createContext(
|
||||
methods: string[],
|
||||
request: (method: string, params: Record<string, unknown>) => Promise<unknown>,
|
||||
) {
|
||||
const client = { request: vi.fn(request) } as unknown as GatewayBrowserClient;
|
||||
const snapshot: ApplicationGatewaySnapshot = {
|
||||
client,
|
||||
phase: "connected",
|
||||
offlineStable: false,
|
||||
canvasPluginSurfaceUrl: null,
|
||||
hello: { features: { methods } } as ApplicationGatewaySnapshot["hello"],
|
||||
assistantAgentId: null,
|
||||
sessionKey: "main",
|
||||
lastError: null,
|
||||
lastErrorCode: null,
|
||||
};
|
||||
const eventListeners = new Set<(event: GatewayEventFrame) => void>();
|
||||
const gateway = {
|
||||
snapshot,
|
||||
connection: {
|
||||
gatewayUrl: "wss://gateway.example.test:18789/control",
|
||||
token: "",
|
||||
bootstrapToken: "",
|
||||
password: "",
|
||||
},
|
||||
subscribe: () => () => undefined,
|
||||
subscribeEvents(listener: (event: GatewayEventFrame) => void) {
|
||||
eventListeners.add(listener);
|
||||
return () => eventListeners.delete(listener);
|
||||
},
|
||||
} as unknown as ApplicationContext["gateway"];
|
||||
return {
|
||||
context: { gateway } as unknown as ApplicationContext,
|
||||
emitPortals(portals: PortalSummary[]) {
|
||||
for (const listener of eventListeners) {
|
||||
listener({ type: "event", event: "portal.changed", payload: { portals } });
|
||||
}
|
||||
},
|
||||
request: client.request,
|
||||
};
|
||||
}
|
||||
|
||||
async function mountPage(context: ApplicationContext) {
|
||||
const page = document.createElement("openclaw-portals-page") as PortalsPageTestElement;
|
||||
page.context = context;
|
||||
document.body.append(page);
|
||||
await page.updateComplete;
|
||||
return page;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("PortalsPage", () => {
|
||||
it("renders the portal list and applies full replacement events", async () => {
|
||||
const source = createContext(["portal.list", "portal.close"], async (method) => {
|
||||
if (method === "portal.list") {
|
||||
return { portals: [portal] } satisfies PortalListResult;
|
||||
}
|
||||
return { closed: true };
|
||||
});
|
||||
const page = await mountPage(source.context);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(page.querySelector(".portals-rail__title")?.textContent).toBe("Seeded app");
|
||||
});
|
||||
expect(page.querySelector(".portals-rail__item")?.textContent).toContain("Port 3000");
|
||||
expect(page.querySelector(".portals-rail__item")?.textContent).toContain(
|
||||
"Use the seeded test account.",
|
||||
);
|
||||
const frame = page.querySelector("iframe");
|
||||
expect(frame?.getAttribute("src")).toBe(
|
||||
"https://gateway.example.test:43123/app?openclaw_portal=secret-token",
|
||||
);
|
||||
expect(frame?.getAttribute("referrerpolicy")).toBe("no-referrer");
|
||||
expect(frame?.getAttribute("sandbox")).toBe(
|
||||
"allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts",
|
||||
);
|
||||
|
||||
source.emitPortals([]);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(page.querySelector(".portals-rail__item")).toBeNull();
|
||||
expect(page.textContent).toContain("Ask the agent to start a portal:");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the empty prompts and an unsupported note without calling the method", async () => {
|
||||
const source = createContext([], async () => ({ portals: [] }));
|
||||
const page = await mountPage(source.context);
|
||||
|
||||
expect(page.textContent).toContain("Ask the agent to start a portal:");
|
||||
expect(page.textContent).toContain("Show me in a portal.");
|
||||
expect(page.textContent).toContain("Start the application in a portal.");
|
||||
expect(page.textContent).toContain("Make the server available in a portal.");
|
||||
expect(page.textContent).toContain("This gateway does not support portals.");
|
||||
expect(source.request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePortalUrl", () => {
|
||||
it("uses the resolved gateway host and scheme with the portal listener port", () => {
|
||||
expect(
|
||||
resolvePortalUrl(
|
||||
portal,
|
||||
"wss://gateway.example.test:18789/control",
|
||||
"http://control-ui.example.test",
|
||||
),
|
||||
).toBe("https://gateway.example.test:43123/app?openclaw_portal=secret-token");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type {
|
||||
PortalChangedEvent,
|
||||
PortalCloseResult,
|
||||
PortalListResult,
|
||||
PortalSummary,
|
||||
} from "@openclaw/gateway-protocol";
|
||||
import { html, nothing } from "lit";
|
||||
import { state } from "lit/decorators.js";
|
||||
import { keyed } from "lit/directives/keyed.js";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import { titleForRoute } from "../../app-navigation.ts";
|
||||
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
|
||||
import { icon } from "../../components/icons.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { formatUiError } from "../../lib/format-error.ts";
|
||||
import { canCallGatewayMethod, isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
|
||||
import { GatewayPageController } from "../../lit/gateway-page-controller.ts";
|
||||
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
|
||||
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
|
||||
import { resolvePortalUrl } from "./portal-url.ts";
|
||||
import "./portals.css";
|
||||
|
||||
const PORTAL_FRAME_SANDBOX =
|
||||
"allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts";
|
||||
|
||||
class PortalsPage extends OpenClawLightDomElement {
|
||||
@consume({ context: applicationContext, subscribe: true })
|
||||
private context!: ApplicationContext;
|
||||
|
||||
@state() private portals: PortalSummary[] = [];
|
||||
@state() private selectedPortalId: string | null = null;
|
||||
@state() private loading = false;
|
||||
@state() private loaded = false;
|
||||
@state() private error: string | null = null;
|
||||
@state() private closingPortalId: string | null = null;
|
||||
|
||||
private requestGeneration = 0;
|
||||
private portalSetRevision = 0;
|
||||
private readonly gateway = new GatewayPageController(this, {
|
||||
getGateway: () => this.context?.gateway,
|
||||
invalidateRequests: () => this.resetGatewayState(),
|
||||
ensureInitialData: () => void this.loadPortals(),
|
||||
});
|
||||
private readonly subscriptions = new SubscriptionsController(this).effect(
|
||||
() => this.context?.gateway,
|
||||
(gateway) =>
|
||||
gateway.subscribeEvents((event) => {
|
||||
if (
|
||||
this.gateway.gateway !== gateway ||
|
||||
this.context.gateway !== gateway ||
|
||||
!this.gateway.connected ||
|
||||
event.event !== "portal.changed"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const portals = (event.payload as Partial<PortalChangedEvent> | null)?.portals;
|
||||
if (Array.isArray(portals)) {
|
||||
this.applyPortalSet(portals);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
override disconnectedCallback() {
|
||||
this.subscriptions.clear();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private get portalListSupported(): boolean {
|
||||
return isGatewayMethodAdvertised(this.gateway.snapshot ?? {}, "portal.list") !== false;
|
||||
}
|
||||
|
||||
private get canClosePortal(): boolean {
|
||||
return canCallGatewayMethod(this.gateway.snapshot, "portal.close", "operator.write");
|
||||
}
|
||||
|
||||
private resetGatewayState() {
|
||||
this.requestGeneration += 1;
|
||||
this.portalSetRevision += 1;
|
||||
this.portals = [];
|
||||
this.selectedPortalId = null;
|
||||
this.loading = false;
|
||||
this.loaded = false;
|
||||
this.error = null;
|
||||
this.closingPortalId = null;
|
||||
}
|
||||
|
||||
private applyPortalSet(portals: readonly PortalSummary[]) {
|
||||
this.portalSetRevision += 1;
|
||||
this.portals = [...portals];
|
||||
this.selectedPortalId = portals.some((portal) => portal.id === this.selectedPortalId)
|
||||
? this.selectedPortalId
|
||||
: (portals[0]?.id ?? null);
|
||||
this.loaded = true;
|
||||
this.error = null;
|
||||
}
|
||||
|
||||
private async loadPortals() {
|
||||
if (!this.gateway.connected || !this.portalListSupported || this.loading) {
|
||||
return;
|
||||
}
|
||||
const client = this.gateway.client;
|
||||
const scope = this.gateway.capture();
|
||||
if (!client || !scope) {
|
||||
return;
|
||||
}
|
||||
const generation = ++this.requestGeneration;
|
||||
const portalSetRevision = this.portalSetRevision;
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const result = await client.request<PortalListResult>("portal.list", {});
|
||||
if (
|
||||
generation === this.requestGeneration &&
|
||||
portalSetRevision === this.portalSetRevision &&
|
||||
this.gateway.isCurrent(scope)
|
||||
) {
|
||||
this.applyPortalSet(result.portals);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
generation === this.requestGeneration &&
|
||||
this.gateway.isCurrent(scope) &&
|
||||
this.portalListSupported
|
||||
) {
|
||||
this.error = t("portalsPage.loadFailed", { error: formatUiError(error) });
|
||||
this.loaded = true;
|
||||
}
|
||||
} finally {
|
||||
if (generation === this.requestGeneration && this.gateway.isCurrent(scope)) {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async closePortal(portal: PortalSummary) {
|
||||
if (!this.canClosePortal || this.closingPortalId) {
|
||||
return;
|
||||
}
|
||||
const client = this.gateway.client;
|
||||
const scope = this.gateway.capture();
|
||||
if (!client || !scope) {
|
||||
return;
|
||||
}
|
||||
this.closingPortalId = portal.id;
|
||||
this.error = null;
|
||||
try {
|
||||
await client.request<PortalCloseResult>("portal.close", { id: portal.id });
|
||||
if (this.gateway.isCurrent(scope)) {
|
||||
void this.loadPortals();
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.gateway.isCurrent(scope)) {
|
||||
this.error = t("portalsPage.closeFailed", { error: formatUiError(error) });
|
||||
}
|
||||
} finally {
|
||||
if (this.gateway.isCurrent(scope) && this.closingPortalId === portal.id) {
|
||||
this.closingPortalId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private renderEmptyState() {
|
||||
const unsupported = !this.portalListSupported;
|
||||
return html`
|
||||
<section class="portals-empty" role="status" aria-live="polite">
|
||||
${this.loading && !this.loaded
|
||||
? html`<div class="portals-empty__title">${t("portalsPage.loading")}</div>`
|
||||
: html`
|
||||
<div class="portals-empty__title">${t("portalsPage.emptyHint")}</div>
|
||||
<div class="portals-empty__prompts">
|
||||
<span>${t("portalsPage.promptShow")}</span>
|
||||
<span>${t("portalsPage.promptStart")}</span>
|
||||
<span>${t("portalsPage.promptMakeAvailable")}</span>
|
||||
</div>
|
||||
`}
|
||||
${unsupported
|
||||
? html`<div class="portals-empty__note">${t("portalsPage.unsupported")}</div>`
|
||||
: nothing}
|
||||
${this.error ? html`<div class="callout danger">${this.error}</div>` : nothing}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPortal(portal: PortalSummary) {
|
||||
const portalUrl = resolvePortalUrl(
|
||||
portal,
|
||||
this.context.gateway.connection.gatewayUrl,
|
||||
window.location.origin,
|
||||
);
|
||||
const frameKey = `${portal.id}\u0000${portalUrl}`;
|
||||
return html`
|
||||
<section class="portals-preview">
|
||||
<header class="portals-preview__header">
|
||||
<a
|
||||
class="portals-preview__url"
|
||||
href=${portalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title=${portalUrl}
|
||||
>
|
||||
<span>${portalUrl}</span>
|
||||
${icon("externalLink")}
|
||||
<span class="sr-only">${t("portalsPage.openNewTab")}</span>
|
||||
</a>
|
||||
<button
|
||||
class="btn btn--icon btn--ghost portals-preview__close"
|
||||
type="button"
|
||||
title=${t("portalsPage.closePortal", { title: portal.title })}
|
||||
aria-label=${t("portalsPage.closePortal", { title: portal.title })}
|
||||
?disabled=${!this.canClosePortal || this.closingPortalId === portal.id}
|
||||
@click=${() => void this.closePortal(portal)}
|
||||
>
|
||||
${icon("x")}
|
||||
</button>
|
||||
</header>
|
||||
${this.error
|
||||
? html`<div class="callout danger portals-preview__error">${this.error}</div>`
|
||||
: nothing}
|
||||
${keyed(
|
||||
frameKey,
|
||||
html`<iframe
|
||||
${ref((element) => {
|
||||
if (element instanceof HTMLIFrameElement && !element.hasAttribute("src")) {
|
||||
element.setAttribute("src", portalUrl);
|
||||
}
|
||||
})}
|
||||
class="portals-preview__frame"
|
||||
title=${t("portalsPage.previewTitle", { title: portal.title })}
|
||||
referrerpolicy="no-referrer"
|
||||
sandbox=${PORTAL_FRAME_SANDBOX}
|
||||
></iframe>`,
|
||||
)}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const selectedPortal =
|
||||
this.portals.find((portal) => portal.id === this.selectedPortalId) ?? this.portals[0];
|
||||
return html`
|
||||
<section class="content-header content-header--page">
|
||||
<div>
|
||||
<div class="page-title">${titleForRoute("portals")}</div>
|
||||
</div>
|
||||
</section>
|
||||
${selectedPortal
|
||||
? html`
|
||||
<section class="portals-layout">
|
||||
<aside class="portals-rail" aria-label=${t("portalsPage.listLabel")}>
|
||||
${this.portals.map(
|
||||
(portal) => html`
|
||||
<button
|
||||
class="portals-rail__item ${portal.id === selectedPortal.id ? "active" : ""}"
|
||||
type="button"
|
||||
aria-current=${portal.id === selectedPortal.id ? "true" : nothing}
|
||||
@click=${() => (this.selectedPortalId = portal.id)}
|
||||
>
|
||||
<span class="portals-rail__title">${portal.title}</span>
|
||||
<span class="portals-rail__port"
|
||||
>${t("portalsPage.portLabel", { port: String(portal.port) })}</span
|
||||
>
|
||||
${portal.description
|
||||
? html`<span class="portals-rail__description">${portal.description}</span>`
|
||||
: nothing}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</aside>
|
||||
${this.renderPortal(selectedPortal)}
|
||||
</section>
|
||||
`
|
||||
: this.renderEmptyState()}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-portals-page")) {
|
||||
customElements.define("openclaw-portals-page", PortalsPage);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
.portals-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(210px, 260px) minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
min-height: min(720px, calc(100vh - 190px));
|
||||
}
|
||||
|
||||
.portals-rail {
|
||||
min-width: 0;
|
||||
padding: 4px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--panel) 76%, transparent);
|
||||
}
|
||||
|
||||
.portals-rail__item {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.portals-rail__item:hover,
|
||||
.portals-rail__item:focus-visible {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.portals-rail__item.active {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.portals-rail__title {
|
||||
overflow: hidden;
|
||||
color: var(--text-strong);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.portals-rail__port,
|
||||
.portals-rail__description {
|
||||
color: var(--muted);
|
||||
font-size: var(--control-ui-text-xs);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.portals-rail__description {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.portals-preview {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.portals-preview__header {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px 8px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.portals-preview__url {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: var(--control-ui-text-xs);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.portals-preview__url:hover,
|
||||
.portals-preview__url:focus-visible {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.portals-preview__url span:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.portals-preview__url svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.portals-preview__close {
|
||||
opacity: 0.42;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.portals-preview:hover .portals-preview__close,
|
||||
.portals-preview__close:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.portals-preview__error {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.portals-preview__frame {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 480px;
|
||||
border: 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.portals-empty {
|
||||
min-height: min(620px, calc(100vh - 190px));
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-6) var(--space-4);
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.portals-empty__title {
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.portals-empty__prompts {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
font-size: var(--control-ui-text-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.portals-empty__note {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--warn);
|
||||
font-size: var(--control-ui-text-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.portals-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.portals-rail {
|
||||
max-height: 190px;
|
||||
}
|
||||
|
||||
.portals-preview {
|
||||
min-height: 560px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { definePage } from "@openclaw/uirouter";
|
||||
import { html } from "lit";
|
||||
import { routePageSpec } from "../../app-route-paths.ts";
|
||||
|
||||
export const page = definePage({
|
||||
...routePageSpec("portals"),
|
||||
component: () =>
|
||||
import("./portals-page.ts").then(() => ({
|
||||
header: true,
|
||||
render: () => html`<openclaw-portals-page></openclaw-portals-page>`,
|
||||
})),
|
||||
});
|
||||
Reference in New Issue
Block a user