fix(ui): discussion button stays open on second click (#112671)

* fix(ui): toggle discussion sidebar from header

* test(ui): cover discussion sidebar toggle
This commit is contained in:
Peter Steinberger
2026-07-22 08:38:36 -07:00
committed by GitHub
parent b56006babf
commit 96d16c3c46
4 changed files with 148 additions and 3 deletions
@@ -0,0 +1,111 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser, type BrowserContext } from "playwright";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
} from "../test-helpers/control-ui-e2e.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"session-discussion-toggle",
);
let server: ControlUiE2eServer;
let browser: Browser;
const openContexts = new Set<BrowserContext>();
async function closeOpenContexts(): Promise<void> {
const contexts = Array.from(openContexts);
openContexts.clear();
await Promise.all(contexts.map((context) => context.close()));
}
describeControlUiE2e("session discussion toggle", () => {
beforeAll(async () => {
if (!chromiumAvailable) {
throw new Error(`Playwright Chromium is unavailable at ${chromiumExecutablePath}`);
}
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
server = await startControlUiE2eServer();
if (captureUiProof) {
await mkdir(proofDir, { recursive: true });
}
});
afterEach(closeOpenContexts);
afterAll(async () => {
await closeOpenContexts();
await browser?.close();
await server?.close();
});
it("opens and closes the sidebar from the same header action", async () => {
const context = await browser.newContext({
...(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-proof";
const gateway = await installMockGateway(page, {
featureMethods: ["session.discussion.info", "session.discussion.open"],
historyMessages: [
{
content: [{ type: "text", text: "Discussion toggle proof." }],
role: "assistant",
timestamp: Date.now(),
},
],
methodResponses: {
"session.discussion.info": { state: "available" },
"session.discussion.open": {
openUrl: "https://discussion.example/session",
state: "open",
},
},
sessionKey,
});
await page.goto(`${server.baseUrl}chat?session=${encodeURIComponent(sessionKey)}`);
await gateway.waitForRequest("session.discussion.info");
const showDiscussion = page.getByRole("button", { name: "Show discussion" });
await expect.poll(() => showDiscussion.isVisible()).toBe(true);
await expect.poll(() => showDiscussion.getAttribute("aria-pressed")).toBe("false");
await showDiscussion.click();
const hideDiscussion = page.getByRole("button", { name: "Hide discussion" });
const closeSidebar = page.getByRole("button", { name: "Close sidebar" });
await expect.poll(() => hideDiscussion.getAttribute("aria-pressed")).toBe("true");
await expect.poll(() => closeSidebar.isVisible()).toBe(true);
expect(await gateway.getRequests("session.discussion.open")).toHaveLength(1);
if (captureUiProof) {
await page.screenshot({ path: path.join(proofDir, "discussion-open.png") });
}
await hideDiscussion.click();
await expect.poll(() => closeSidebar.isVisible()).toBe(false);
await expect.poll(() => showDiscussion.getAttribute("aria-pressed")).toBe("false");
expect(await gateway.getRequests("session.discussion.open")).toHaveLength(1);
if (captureUiProof) {
await page.screenshot({ path: path.join(proofDir, "discussion-closed.png") });
}
});
});
+1
View File
@@ -4357,6 +4357,7 @@ export const en: TranslationMap = {
sessionDiscussion: {
title: "Discussion",
show: "Show discussion",
hide: "Hide discussion",
disconnected: "Gateway is disconnected.",
loading: "Loading discussion…",
opening: "Opening discussion…",
@@ -1,5 +1,6 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import type { SessionDiscussionInfo } from "../../../../packages/gateway-protocol/src/index.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
@@ -10,6 +11,7 @@ import "./components/chat-sidebar.ts";
type DiscussionTestPane = TestChatPane & {
probeSessionDiscussion: (sessionKey: string) => Promise<void>;
renderSessionDiscussionAction: () => unknown;
};
const SESSION_KEY = "agent:main:current";
@@ -35,9 +37,13 @@ function createDiscussionPane(params: {
state.sidebarContent = content;
state.sidebarOpen = true;
});
const handleCloseSidebar = vi.fn(() => {
state.sidebarOpen = false;
});
state.handleOpenSidebar = handleOpenSidebar;
state.handleCloseSidebar = handleCloseSidebar;
state.sidebarOpen = params.sidebarOpen ?? false;
return { pane, state, handleOpenSidebar, request };
return { pane, state, handleOpenSidebar, handleCloseSidebar, request };
}
describe("chat pane session discussion auto-show", () => {
@@ -100,6 +106,33 @@ describe("chat pane session discussion auto-show", () => {
expect(handleOpenSidebar).not.toHaveBeenCalled();
});
it("uses the header action to open and close the discussion sidebar", async () => {
const { pane, handleOpenSidebar, handleCloseSidebar } = createDiscussionPane({
info: { state: "available" },
});
const container = document.createElement("div");
document.body.append(container);
await pane.probeSessionDiscussion(SESSION_KEY);
render(pane.renderSessionDiscussionAction(), container);
let action = container.querySelector<HTMLButtonElement>(".chat-session-discussion-toggle");
expect(action?.ariaLabel).toBe("Show discussion");
expect(action?.getAttribute("aria-pressed")).toBe("false");
action?.click();
expect(handleOpenSidebar).toHaveBeenCalledTimes(1);
render(pane.renderSessionDiscussionAction(), container);
action = container.querySelector<HTMLButtonElement>(".chat-session-discussion-toggle");
expect(action?.ariaLabel).toBe("Hide discussion");
expect(action?.getAttribute("aria-pressed")).toBe("true");
action?.click();
expect(handleCloseSidebar).toHaveBeenCalledTimes(1);
expect(handleOpenSidebar).toHaveBeenCalledTimes(1);
container.remove();
});
it("does not steal a sidebar that is already open", async () => {
const { pane, handleOpenSidebar } = createDiscussionPane({
info: { state: "open", embedUrl: "https://clack.example/embed/c1" },
+2 -2
View File
@@ -3124,7 +3124,7 @@ class ChatPane extends OpenClawLightDomElement {
state.sidebarOpen &&
state.sidebarContent?.kind === "session-discussion" &&
state.sidebarContent.sessionKey === sessionKey;
const label = t("chat.sessionDiscussion.show");
const label = t(active ? "chat.sessionDiscussion.hide" : "chat.sessionDiscussion.show");
return html`
<openclaw-tooltip .content=${label}>
<button
@@ -3132,7 +3132,7 @@ class ChatPane extends OpenClawLightDomElement {
type="button"
aria-label=${label}
aria-pressed=${String(active)}
@click=${() => state.handleOpenSidebar(content)}
@click=${() => (active ? state.handleCloseSidebar() : state.handleOpenSidebar(content))}
>
${icons.messageSquare}
</button>