diff --git a/extensions/clickclack/README.md b/extensions/clickclack/README.md index d8e3165dedc9..4dbdfcbb15bc 100644 --- a/extensions/clickclack/README.md +++ b/extensions/clickclack/README.md @@ -106,6 +106,11 @@ defaults to the account workspace, and `section` defaults to `Sessions`. `controlUrlBase` adds a link back to `/chat?session=` in the OpenClaw Control UI. +ClickClack-managed embed URLs explicitly advertise host-theme support. The +Control UI uses that provider-owned capability to apply its full palette before +the sidebar first paints and to stream live palette changes without rewriting +opaque or signed discussion URLs from other providers. + Enable discussions on exactly one ClickClack account. Multiple enabled discussion accounts are rejected because the session discussion provider does not have an account selector. diff --git a/extensions/clickclack/src/discussions/eligibility.ts b/extensions/clickclack/src/discussions/eligibility.ts index d6a7819639b5..e815cf787ee6 100644 --- a/extensions/clickclack/src/discussions/eligibility.ts +++ b/extensions/clickclack/src/discussions/eligibility.ts @@ -52,7 +52,9 @@ export function discussionInfoForBinding( const baseUrl = normalizedServerBaseUrl(account); return { state: "open", - embedUrl: `${baseUrl}/embed/channel/${encodeURIComponent(binding.workspaceRouteId)}/${encodeURIComponent(binding.channelRouteId)}`, + // Only this provider may opt into host-owned theme parameters; signed + // discussion URLs from other providers must remain opaque. + embedUrl: `${baseUrl}/embed/channel/${encodeURIComponent(binding.workspaceRouteId)}/${encodeURIComponent(binding.channelRouteId)}?openclawHostTheme=1`, openUrl: `${baseUrl}/app/${encodeURIComponent(binding.workspaceRouteId)}/${encodeURIComponent(binding.channelRouteId)}`, }; } diff --git a/extensions/clickclack/src/discussions/service.test.ts b/extensions/clickclack/src/discussions/service.test.ts index 7afa28714686..f45089297eeb 100644 --- a/extensions/clickclack/src/discussions/service.test.ts +++ b/extensions/clickclack/src/discussions/service.test.ts @@ -140,7 +140,8 @@ describe("ClickClack discussion service", () => { expect(opened).toEqual({ state: "open", - embedUrl: "https://clickclack.example/embed/channel/team-route/discussion-route", + embedUrl: + "https://clickclack.example/embed/channel/team-route/discussion-route?openclawHostTheme=1", openUrl: "https://clickclack.example/app/team-route/discussion-route", }); expect(reopened).toEqual(opened); @@ -595,7 +596,8 @@ describe("ClickClack discussion service", () => { ); expect(opened).toEqual({ state: "open", - embedUrl: "https://clickclack.example/embed/channel/team-route/recovered-route", + embedUrl: + "https://clickclack.example/embed/channel/team-route/recovered-route?openclawHostTheme=1", openUrl: "https://clickclack.example/app/team-route/recovered-route", }); }); diff --git a/ui/src/e2e/chat-session-discussion-toggle.e2e.test.ts b/ui/src/e2e/chat-session-discussion-toggle.e2e.test.ts index 6f5fdb3a9674..8ebef506626d 100644 --- a/ui/src/e2e/chat-session-discussion-toggle.e2e.test.ts +++ b/ui/src/e2e/chat-session-discussion-toggle.e2e.test.ts @@ -109,4 +109,125 @@ describeControlUiE2e("session discussion toggle", () => { await page.screenshot({ path: path.join(proofDir, "discussion-closed.png") }); } }); + + it("keeps a cross-origin discussion in sync when the real host color scheme changes", async () => { + const context = await browser.newContext({ + colorScheme: "light", + ...(captureUiProof + ? { recordVideo: { dir: proofDir, size: { height: 720, width: 1280 } } } + : {}), + viewport: { height: 720, width: 1280 }, + }); + openContexts.add(context); + const page = await context.newPage(); + const sessionKey = "agent:main:discussion-theme-proof"; + + await page.route("https://discussion.example/embed/channel/**", (route) => + route.fulfill({ + contentType: "text/html; charset=utf-8", + body: ` +

Cross-origin discussion

+

The sidebar follows its OpenClaw host.

+ `, + }), + ); + + const gateway = await installMockGateway(page, { + featureMethods: ["session.discussion.info", "session.discussion.open"], + historyMessages: [ + { + content: [{ type: "text", text: "Cross-origin theme proof." }], + role: "assistant", + timestamp: Date.now(), + }, + ], + methodResponses: { + "session.discussion.info": { state: "available" }, + "session.discussion.open": { + embedUrl: "https://discussion.example/embed/channel/T1/C1?openclawHostTheme=1", + openUrl: "https://discussion.example/app/T1/C1", + state: "open", + }, + }, + sessionKey, + }); + + await page.goto(controlUiSessionUrl(server.baseUrl, sessionKey)); + await gateway.waitForRequest("session.discussion.info"); + await page.getByRole("button", { name: "Show discussion" }).click(); + + const frameElement = page.locator("iframe.session-discussion__frame"); + await expect.poll(() => frameElement.count()).toBe(1); + await expect + .poll(() => + page + .frames() + .some((candidate) => + candidate.url().startsWith("https://discussion.example/embed/channel/"), + ), + ) + .toBe(true); + const frame = page + .frames() + .find((candidate) => candidate.url().startsWith("https://discussion.example/embed/channel/")); + expect(frame).toBeDefined(); + + const frameUrl = new URL(frame!.url()); + expect(frameUrl.searchParams.get("theme")).toBe("light"); + expect(frameUrl.searchParams.get("hostOrigin")).toBe(new URL(server.baseUrl).origin); + await expect.poll(() => frame!.locator("html").getAttribute("data-host-mode")).toBe("light"); + await expect + .poll(() => + frame!.evaluate(() => + getComputedStyle(document.documentElement).getPropertyValue("--host-surface").trim(), + ), + ) + .not.toBe(""); + if (captureUiProof) { + await page.screenshot({ path: path.join(proofDir, "discussion-theme-light.png") }); + } + + await page.emulateMedia({ colorScheme: "dark" }); + + await expect + .poll(() => page.evaluate(() => document.documentElement.dataset.themeMode)) + .toBe("dark"); + await expect.poll(() => frame!.locator("html").getAttribute("data-host-mode")).toBe("dark"); + await expect + .poll(async () => { + const [hostSurface, embeddedSurface] = await Promise.all([ + page.evaluate(() => + getComputedStyle(document.documentElement).getPropertyValue("--bg").trim(), + ), + frame!.evaluate(() => + getComputedStyle(document.documentElement).getPropertyValue("--host-surface").trim(), + ), + ]); + return { hostSurface, embeddedSurface }; + }) + .toEqual({ hostSurface: "#0e1015", embeddedSurface: "#0e1015" }); + expect(new URL(frame!.url()).searchParams.get("theme")).toBe("light"); + if (captureUiProof) { + await page.screenshot({ path: path.join(proofDir, "discussion-theme-dark.png") }); + } + }); }); diff --git a/ui/src/pages/chat/components/session-discussion-panel.test.ts b/ui/src/pages/chat/components/session-discussion-panel.test.ts index 231fe6b9f32d..e4eaf008a2b1 100644 --- a/ui/src/pages/chat/components/session-discussion-panel.test.ts +++ b/ui/src/pages/chat/components/session-discussion-panel.test.ts @@ -22,8 +22,25 @@ const panels: DiscussionPanelElement[] = []; afterEach(() => { panels.splice(0).forEach((panel) => panel.remove()); + document.documentElement.removeAttribute("data-theme"); + document.documentElement.removeAttribute("data-theme-mode"); + document.documentElement.removeAttribute("style"); + vi.restoreAllMocks(); }); +function expectedEmbedUrl(url: string, mode: "light" | "dark" = "dark"): string { + const resolved = new URL(url); + if ( + resolved.searchParams.get("openclawHostTheme") !== "1" || + !/^\/embed\/(?:channel|thread)\/[^/]+\/[^/]+\/?$/u.test(resolved.pathname) + ) { + return resolved.href; + } + resolved.searchParams.set("theme", mode); + resolved.searchParams.set("hostOrigin", window.location.origin); + return resolved.href; +} + function mount(params: { loadInfo: SessionDiscussionInfoLoader; openDiscussion: SessionDiscussionOpener; @@ -56,7 +73,7 @@ describe("session discussion panel", () => { await vi.waitFor(() => { expect(panel.querySelector("iframe")?.getAttribute("src")).toBe( - "https://discussion.example/embed/thread", + expectedEmbedUrl("https://discussion.example/embed/thread"), ); expect(panel.querySelector("iframe")?.getAttribute("sandbox")).toBe( "allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts", @@ -74,6 +91,179 @@ describe("session discussion panel", () => { expect(panel.querySelector("a")).toBeNull(); }); + it("selects the host color mode before the discussion frame paints", async () => { + document.documentElement.dataset.themeMode = "light"; + const panel = mount({ + loadInfo: vi.fn().mockResolvedValue({ + state: "open", + embedUrl: + "https://discussion.example/embed/channel/T1/C1?openclawHostTheme=1&existing=1#messages", + }), + openDiscussion: vi.fn(), + }); + + await vi.waitFor(() => { + expect(panel.querySelector("iframe")?.getAttribute("src")).toBe( + expectedEmbedUrl( + "https://discussion.example/embed/channel/T1/C1?openclawHostTheme=1&existing=1#messages", + "light", + ), + ); + }); + }); + + it("preserves signed provider URLs even when their routes resemble ClickClack", async () => { + const signedUrl = + "https://discussion.example/embed/channel/a/b?signature=abc%2B123&expires=1785200000#thread"; + document.documentElement.dataset.themeMode = "light"; + document.documentElement.style.setProperty("--bg", "#faf9f7"); + const panel = mount({ + loadInfo: vi.fn().mockResolvedValue({ + state: "open", + embedUrl: signedUrl, + }), + openDiscussion: vi.fn(), + }); + + await vi.waitFor(() => { + expect(panel.querySelector("iframe")?.src).toBe(signedUrl); + }); + const frame = panel.querySelector("iframe")!; + const postMessage = vi.spyOn(frame.contentWindow!, "postMessage"); + + frame.dispatchEvent(new Event("load")); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "openclaw:widget-theme", + tokens: expect.objectContaining({ surface: "#faf9f7" }), + }), + "https://discussion.example", + ); + }); + + it("posts the complete host palette to the exact discussion origin on frame load", async () => { + document.documentElement.dataset.themeMode = "light"; + document.documentElement.style.setProperty("--bg", "#faf9f7"); + document.documentElement.style.setProperty("--card", "#ffffff"); + document.documentElement.style.setProperty("--accent", "#bd4531"); + const panel = mount({ + loadInfo: vi.fn().mockResolvedValue({ + state: "open", + embedUrl: "https://discussion.example/embed/channel/T1/C1?openclawHostTheme=1", + }), + openDiscussion: vi.fn(), + }); + + await vi.waitFor(() => expect(panel.querySelector("iframe")).not.toBeNull()); + const frame = panel.querySelector("iframe")!; + const postMessage = vi.spyOn(frame.contentWindow!, "postMessage"); + + frame.dispatchEvent(new Event("load")); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "openclaw:widget-theme", + mode: "light", + tokens: expect.objectContaining({ + surface: "#faf9f7", + card: "#ffffff", + accent: "#bd4531", + }), + }), + "https://discussion.example", + ); + }); + + it("includes custom host palette tokens in the first-paint discussion URL", async () => { + document.documentElement.dataset.themeMode = "dark"; + document.documentElement.style.setProperty("--bg", "#171229"); + document.documentElement.style.setProperty("--card", "#211a36"); + document.documentElement.style.setProperty("--accent", "#c084fc"); + const panel = mount({ + loadInfo: vi.fn().mockResolvedValue({ + state: "open", + embedUrl: "https://discussion.example/embed/channel/T1/C1?openclawHostTheme=1", + }), + openDiscussion: vi.fn(), + }); + + await vi.waitFor(() => expect(panel.querySelector("iframe")).not.toBeNull()); + const embedUrl = new URL(panel.querySelector("iframe")!.src); + + expect(embedUrl.searchParams.get("theme")).toBe("dark"); + expect(JSON.parse(embedUrl.searchParams.get("themeTokens")!)).toEqual( + expect.objectContaining({ + surface: "#171229", + card: "#211a36", + accent: "#c084fc", + }), + ); + }); + + it("updates an existing discussion frame when the host theme changes", async () => { + document.documentElement.dataset.themeMode = "light"; + document.documentElement.style.setProperty("--bg", "#faf9f7"); + const panel = mount({ + loadInfo: vi.fn().mockResolvedValue({ + state: "open", + embedUrl: "https://discussion.example/embed/channel/T1/C1?openclawHostTheme=1", + }), + openDiscussion: vi.fn(), + }); + + await vi.waitFor(() => expect(panel.querySelector("iframe")).not.toBeNull()); + const frame = panel.querySelector("iframe")!; + const originalUrl = frame.src; + const postMessage = vi.spyOn(frame.contentWindow!, "postMessage"); + + document.documentElement.dataset.themeMode = "dark"; + document.documentElement.style.setProperty("--bg", "#0e1015"); + + await vi.waitFor(() => { + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "openclaw:widget-theme", + mode: "dark", + tokens: expect.objectContaining({ surface: "#0e1015" }), + }), + "https://discussion.example", + ); + }); + expect(frame.src).toBe(originalUrl); + }); + + it("updates the discussion when only the custom host palette changes", async () => { + document.documentElement.dataset.themeMode = "dark"; + document.documentElement.style.setProperty("--bg", "#171229"); + const panel = mount({ + loadInfo: vi.fn().mockResolvedValue({ + state: "open", + embedUrl: "https://discussion.example/embed/channel/T1/C1?openclawHostTheme=1", + }), + openDiscussion: vi.fn(), + }); + + await vi.waitFor(() => expect(panel.querySelector("iframe")).not.toBeNull()); + const frame = panel.querySelector("iframe")!; + const originalUrl = frame.src; + const postMessage = vi.spyOn(frame.contentWindow!, "postMessage"); + + document.documentElement.style.setProperty("--bg", "#211a36"); + + await vi.waitFor(() => { + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "openclaw:widget-theme", + mode: "dark", + tokens: expect.objectContaining({ surface: "#211a36" }), + }), + "https://discussion.example", + ); + }); + expect(frame.src).toBe(originalUrl); + }); + it("offers the valid open URL when a same-origin embed is rejected", async () => { const openUrl = "https://discussion.example/thread"; const panel = mount({ @@ -180,7 +370,7 @@ describe("session discussion panel", () => { const panel = mount({ loadInfo, openDiscussion: vi.fn() }); await vi.waitFor(() => { expect(panel.querySelector("iframe")?.getAttribute("src")).toBe( - "https://old.example/embed/thread", + expectedEmbedUrl("https://old.example/embed/thread"), ); }); @@ -188,7 +378,7 @@ describe("session discussion panel", () => { await vi.waitFor(() => { expect(panel.querySelector("iframe")?.getAttribute("src")).toBe( - "https://new.example/embed/thread", + expectedEmbedUrl("https://new.example/embed/thread"), ); }); expect(loadInfo).toHaveBeenCalledTimes(2); diff --git a/ui/src/pages/chat/components/session-discussion-panel.ts b/ui/src/pages/chat/components/session-discussion-panel.ts index f259f67dc7df..37b3e2a146ca 100644 --- a/ui/src/pages/chat/components/session-discussion-panel.ts +++ b/ui/src/pages/chat/components/session-discussion-panel.ts @@ -6,6 +6,7 @@ import type { } from "../../../../../packages/gateway-protocol/src/index.js"; import { t } from "../../../i18n/index.ts"; import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts"; +import { buildWidgetThemeMessage, postWidgetTheme } from "./widget-theme.ts"; type SessionDiscussionInfoLoader = (sessionKey: string) => Promise; type SessionDiscussionOpener = (sessionKey: string) => Promise; @@ -45,7 +46,30 @@ function resolveDiscussionEmbedUrl(value: string | undefined): string | null { if (!resolved) { return null; } - return new URL(resolved).origin === window.location.origin ? null : resolved; + const url = new URL(resolved); + if (url.origin === window.location.origin) { + return null; + } + if ( + url.searchParams.get("openclawHostTheme") !== "1" || + !/^\/embed\/(?:channel|thread)\/[^/]+\/[^/]+\/?$/u.test(url.pathname) + ) { + // Provider-issued and signed discussion URLs are opaque. Only ClickClack's + // documented embed routes support the first-paint theme query contract. + return url.href; + } + // The initial URL protects the first paint; hostOrigin binds subsequent + // full-palette messages to this exact Control UI parent. + url.searchParams.set( + "theme", + document.documentElement.dataset.themeMode === "light" ? "light" : "dark", + ); + url.searchParams.set("hostOrigin", window.location.origin); + const themeTokens = buildWidgetThemeMessage().tokens; + if (Object.keys(themeTokens).length > 0) { + url.searchParams.set("themeTokens", JSON.stringify(themeTokens)); + } + return url.href; } class SessionDiscussionPanel extends OpenClawLightDomElement { @@ -62,6 +86,41 @@ class SessionDiscussionPanel extends OpenClawLightDomElement { @state() private error: string | null = null; private requestVersion = 0; + private themeObserver: MutationObserver | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + if (typeof MutationObserver === "undefined") { + return; + } + this.themeObserver = new MutationObserver(() => this.postDiscussionTheme()); + this.themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme", "data-theme-mode", "style"], + }); + } + + override disconnectedCallback(): void { + this.themeObserver?.disconnect(); + this.themeObserver = null; + super.disconnectedCallback(); + } + + private readonly handleDiscussionFrameLoad = (event: Event): void => { + const frame = event.currentTarget; + if (frame instanceof HTMLIFrameElement) { + this.postDiscussionTheme(frame); + } + }; + + private postDiscussionTheme( + frame = this.querySelector(".session-discussion__frame"), + ): void { + if (!frame?.isConnected) { + return; + } + postWidgetTheme(frame, new URL(frame.src).origin); + } private isCurrentRequest(sessionKey: string, version: number): boolean { return version === this.requestVersion && sessionKey === this.sessionKey.trim(); @@ -161,6 +220,7 @@ class SessionDiscussionPanel extends OpenClawLightDomElement { src=${embedUrl} title=${t("chat.sessionDiscussion.frameTitle")} sandbox="allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts" + @load=${this.handleDiscussionFrameLoad} > ` : html`
diff --git a/ui/src/pages/chat/components/widget-theme.test.ts b/ui/src/pages/chat/components/widget-theme.test.ts index 34fe901ca5f2..b6c355a004e2 100644 --- a/ui/src/pages/chat/components/widget-theme.test.ts +++ b/ui/src/pages/chat/components/widget-theme.test.ts @@ -75,6 +75,24 @@ describe("widget theme bridge", () => { }); }); + it("targets the exact origin for authenticated cross-origin embeds", () => { + document.documentElement.dataset.themeMode = "dark"; + stubComputedStyles({ "--bg": "#0e1015", "--accent": "#ff5c5c" }); + const postMessage = vi.fn(); + const frame = { contentWindow: { postMessage } } as unknown as HTMLIFrameElement; + + postWidgetTheme(frame, "https://discussion.example"); + + expect(postedMessage(postMessage)).toEqual([ + { + type: "openclaw:widget-theme", + mode: "dark", + tokens: { surface: "#0e1015", accent: "#ff5c5c" }, + }, + "https://discussion.example", + ]); + }); + it("posts theme changes to connected frames and installs once", () => { class FakeMutationObserver { static instances: FakeMutationObserver[] = []; diff --git a/ui/src/pages/chat/components/widget-theme.ts b/ui/src/pages/chat/components/widget-theme.ts index 1aeff0114b95..55a2593acb3e 100644 --- a/ui/src/pages/chat/components/widget-theme.ts +++ b/ui/src/pages/chat/components/widget-theme.ts @@ -53,7 +53,7 @@ function collectWidgetThemeTokens(read: (hostVar: string) => string): Record; @@ -67,10 +67,10 @@ function buildWidgetThemeMessage(): { }; } -export function postWidgetTheme(frame: HTMLIFrameElement): void { - // Widget documents have opaque origins, so "*" is required; the payload - // contains theme colors only. - frame.contentWindow?.postMessage(buildWidgetThemeMessage(), "*"); +export function postWidgetTheme(frame: HTMLIFrameElement, targetOrigin = "*"): void { + // Canvas widgets have opaque origins and require "*". Authenticated + // cross-origin embeds instead supply their exact, validated origin. + frame.contentWindow?.postMessage(buildWidgetThemeMessage(), targetOrigin); } let widgetThemeObserverInstalled = false;