diff --git a/ui/src/app-navigation-groups.test.ts b/ui/src/app-navigation-groups.test.ts index 93d2694aec0a..4c782a7d2099 100644 --- a/ui/src/app-navigation-groups.test.ts +++ b/ui/src/app-navigation-groups.test.ts @@ -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", diff --git a/ui/src/app-navigation.test.ts b/ui/src/app-navigation.test.ts index a01cd55dcaa8..be407b513805 100644 --- a/ui/src/app-navigation.test.ts +++ b/ui/src/app-navigation.test.ts @@ -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); }); diff --git a/ui/src/app-navigation.ts b/ui/src/app-navigation.ts index 421c4dcc499b..3d5f055295d5 100644 --- a/ui/src/app-navigation.ts +++ b/ui/src/app-navigation.ts @@ -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 { 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 }) => { diff --git a/ui/src/app-route-paths.ts b/ui/src/app-route-paths.ts index f8517d25736c..8b5f9acb2956 100644 --- a/ui/src/app-route-paths.ts +++ b/ui/src/app-route-paths.ts @@ -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" }, diff --git a/ui/src/app-routes.test.ts b/ui/src/app-routes.test.ts index 70f7b1a7fa7a..d26bac2770a6 100644 --- a/ui/src/app-routes.test.ts +++ b/ui/src/app-routes.test.ts @@ -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()); }); diff --git a/ui/src/app-routes.ts b/ui/src/app-routes.ts index f442e09d16c9..c36848326f35 100644 --- a/ui/src/app-routes.ts +++ b/ui/src/app-routes.ts @@ -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, diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 0dd95f270d3f..b36befda1485 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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: diff --git a/ui/src/pages/portals/portal-url.ts b/ui/src/pages/portals/portal-url.ts new file mode 100644 index 000000000000..e52fcb07a2ab --- /dev/null +++ b/ui/src/pages/portals/portal-url.ts @@ -0,0 +1,14 @@ +import type { PortalSummary } from "@openclaw/gateway-protocol"; +import { resolveGatewayHttpOrigin } from "../../components/sandbox-host.ts"; + +export function resolvePortalUrl( + portal: Pick, + 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; +} diff --git a/ui/src/pages/portals/portals-page.test.ts b/ui/src/pages/portals/portals-page.test.ts new file mode 100644 index 000000000000..38121cbe0452 --- /dev/null +++ b/ui/src/pages/portals/portals-page.test.ts @@ -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; +}; + +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) => Promise, +) { + 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"); + }); +}); diff --git a/ui/src/pages/portals/portals-page.ts b/ui/src/pages/portals/portals-page.ts new file mode 100644 index 000000000000..a32b983cf684 --- /dev/null +++ b/ui/src/pages/portals/portals-page.ts @@ -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 | 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("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("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` +
+ ${this.loading && !this.loaded + ? html`
${t("portalsPage.loading")}
` + : html` +
${t("portalsPage.emptyHint")}
+
+ ${t("portalsPage.promptShow")} + ${t("portalsPage.promptStart")} + ${t("portalsPage.promptMakeAvailable")} +
+ `} + ${unsupported + ? html`
${t("portalsPage.unsupported")}
` + : nothing} + ${this.error ? html`
${this.error}
` : nothing} +
+ `; + } + + private renderPortal(portal: PortalSummary) { + const portalUrl = resolvePortalUrl( + portal, + this.context.gateway.connection.gatewayUrl, + window.location.origin, + ); + const frameKey = `${portal.id}\u0000${portalUrl}`; + return html` +
+
+ + ${portalUrl} + ${icon("externalLink")} + ${t("portalsPage.openNewTab")} + + +
+ ${this.error + ? html`
${this.error}
` + : nothing} + ${keyed( + frameKey, + html``, + )} +
+ `; + } + + override render() { + const selectedPortal = + this.portals.find((portal) => portal.id === this.selectedPortalId) ?? this.portals[0]; + return html` +
+
+
${titleForRoute("portals")}
+
+
+ ${selectedPortal + ? html` +
+ + ${this.renderPortal(selectedPortal)} +
+ ` + : this.renderEmptyState()} + `; + } +} + +if (!customElements.get("openclaw-portals-page")) { + customElements.define("openclaw-portals-page", PortalsPage); +} diff --git a/ui/src/pages/portals/portals.css b/ui/src/pages/portals/portals.css new file mode 100644 index 000000000000..bfef3fd60794 --- /dev/null +++ b/ui/src/pages/portals/portals.css @@ -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; + } +} diff --git a/ui/src/pages/portals/route.ts b/ui/src/pages/portals/route.ts new file mode 100644 index 000000000000..96a90c13ce62 --- /dev/null +++ b/ui/src/pages/portals/route.ts @@ -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``, + })), +});