feat(ui): add portals page

This commit is contained in:
Peter Steinberger
2026-08-12 00:17:13 -07:00
parent 2a1839f4c5
commit 1e63928ad0
13 changed files with 678 additions and 0 deletions
+8
View File
@@ -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",
+17
View File
@@ -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);
});
+3
View File
@@ -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" },
+5
View File
@@ -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 }) => {
+1
View File
@@ -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" },
+1
View File
@@ -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());
});
+2
View File
@@ -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,
+17
View File
@@ -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:
+14
View File
@@ -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;
}
+140
View File
@@ -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");
});
});
+280
View File
@@ -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);
}
+178
View File
@@ -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;
}
}
+12
View File
@@ -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>`,
})),
});