From d1f327a4e4aef8a9e19670d8f7da184fb5820761 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 18 Aug 2026 19:05:20 -0700 Subject: [PATCH] fix(ui): refresh Browser when sidebar tab activates (#126058) --- .../browser/browser-panel-controller.ts | 2 + .../components/browser/browser-panel.test.ts | 11 +- ui/src/components/browser/browser-panel.ts | 57 ++++------ .../chat-sidebar-panel-contract.e2e.test.ts | 104 ++++++++++++++++++ .../pages/chat/chat-pane-embedded-panels.ts | 2 + ui/src/pages/chat/chat-pane-layout-render.ts | 4 + 6 files changed, 144 insertions(+), 36 deletions(-) diff --git a/ui/src/components/browser/browser-panel-controller.ts b/ui/src/components/browser/browser-panel-controller.ts index 396c6c9d687e..cf96da0defe7 100644 --- a/ui/src/components/browser/browser-panel-controller.ts +++ b/ui/src/components/browser/browser-panel-controller.ts @@ -120,6 +120,8 @@ export class BrowserPanelController implements ReactiveController { this.input.resetCaptureState(); this.setState("inspected", null); this.setState("inspectPointer", null); + this.urlDraftEditing = false; + this.setState("urlDraft", ""); this.setState("pendingNewTab", false); // Re-probe per connection: another gateway may have evaluate enabled. this.setState("evaluateUnavailable", false); diff --git a/ui/src/components/browser/browser-panel.test.ts b/ui/src/components/browser/browser-panel.test.ts index a3ca17876a5d..0b4a945ed9d6 100644 --- a/ui/src/components/browser/browser-panel.test.ts +++ b/ui/src/components/browser/browser-panel.test.ts @@ -207,9 +207,10 @@ describe("normalizeBrowserUrlDraft", () => { expect(panel.browserPanelIsOpen()).toBe(false); }); - it("treats an embedded panel as open while the side panel owns visibility", async () => { + it("treats an embedded panel as open only while it is presented", async () => { const panel = document.createElement("openclaw-browser-panel") as unknown as HTMLElement & { embedded: boolean; + presented: boolean; browserPanelIsOpen: () => boolean; updateComplete: Promise; }; @@ -217,19 +218,27 @@ describe("normalizeBrowserUrlDraft", () => { document.body.append(panel); await panel.updateComplete; + expect(panel.browserPanelIsOpen()).toBe(false); + panel.presented = true; + await panel.updateComplete; expect(panel.browserPanelIsOpen()).toBe(true); + panel.presented = false; + await panel.updateComplete; + expect(panel.browserPanelIsOpen()).toBe(false); }); it("starts a fresh browser tab draft when an embedded panel receives a new-tab request", async () => { const panel = document.createElement("openclaw-browser-panel") as unknown as HTMLElement & { available: boolean; embedded: boolean; + presented: boolean; handleToggleRequest: (event: Event) => void; renderRoot: ShadowRoot; updateComplete: Promise; }; panel.available = true; panel.embedded = true; + panel.presented = true; document.body.append(panel); await panel.updateComplete; diff --git a/ui/src/components/browser/browser-panel.ts b/ui/src/components/browser/browser-panel.ts index 5ef749adc075..5408638f0ebe 100644 --- a/ui/src/components/browser/browser-panel.ts +++ b/ui/src/components/browser/browser-panel.ts @@ -51,6 +51,8 @@ class OpenClawBrowserPanel extends OpenClawLitElement implements BrowserPanelCon @property({ attribute: false }) authToken: string | null = null; /** Hosted by the chat side panel, which owns visibility and geometry. */ @property({ type: Boolean }) embedded = false; + /** This embedded instance is the active pane's visible Browser presenter. */ + @property({ type: Boolean }) presented = false; private readonly browserPanelController = new BrowserPanelController(this); private readonly dockLayout = new DockLayoutController(this, { @@ -59,7 +61,6 @@ class OpenClawBrowserPanel extends OpenClawLitElement implements BrowserPanelCon isAvailable: () => this.available, }); private readonly onToggleRequest = (event: Event) => this.handleToggleRequest(event); - private embeddedRefreshTimer: number | null = null; private viewportResizeObserver: ResizeObserver | null = null; private observedViewportElement: Element | null = null; @@ -78,13 +79,12 @@ class OpenClawBrowserPanel extends OpenClawLitElement implements BrowserPanelCon // A settings takeover can already own the viewport when the panel mounts. // Suppress before the restored open state refreshes a dock nobody can see. this.dockLayout.setSuppressed(this.suppressed); - if (this.dockLayout.open) { + if (!this.embedded && this.dockLayout.open) { void this.browserPanelController.refreshAll(); } } override disconnectedCallback(): void { - this.clearEmbeddedRefresh(); super.disconnectedCallback(); window.removeEventListener(BROWSER_PANEL_TOGGLE_EVENT, this.onToggleRequest); this.viewportResizeObserver?.disconnect(); @@ -100,18 +100,26 @@ class OpenClawBrowserPanel extends OpenClawLitElement implements BrowserPanelCon window.addEventListener(BROWSER_PANEL_TOGGLE_EVENT, this.onToggleRequest); } } - if (changed.has("suppressed") && this.dockLayout.setSuppressed(this.suppressed)) { + if ( + changed.has("suppressed") && + this.dockLayout.setSuppressed(this.suppressed) && + this.browserPanelIsOpen() + ) { void this.browserPanelController.refreshAll(); } + const gatewayAvailabilityChanged = changed.has("client") || changed.has("available"); + const presentationChanged = + this.embedded && (changed.has("embedded") || changed.has("presented")); const refreshedForClientChange = this.browserPanelController.synchronizeHostProperties(changed); - if ( - this.embedded && - this.available && - !refreshedForClientChange && - (changed.has("embedded") || changed.has("client") || changed.has("available")) - ) { - this.scheduleEmbeddedRefresh(); - } else if (changed.has("client") || changed.has("available")) { + if (this.embedded) { + if (!this.presented || !this.available || !this.client) { + if (presentationChanged || gatewayAvailabilityChanged) { + this.browserPanelController.resetBrowserState(); + } + } else if (!refreshedForClientChange && (presentationChanged || gatewayAvailabilityChanged)) { + void this.browserPanelController.refreshAll(); + } + } else if (gatewayAvailabilityChanged) { if (!this.available && this.dockLayout.open) { // Surface disappeared (disconnect/scope loss): hide without persisting // so the open preference survives a reconnect. @@ -146,27 +154,7 @@ class OpenClawBrowserPanel extends OpenClawLitElement implements BrowserPanelCon } browserPanelIsOpen(): boolean { - return this.embedded || this.dockLayout.open; - } - - private scheduleEmbeddedRefresh(): void { - if (this.embeddedRefreshTimer !== null) { - return; - } - this.embeddedRefreshTimer = window.setTimeout(() => { - this.embeddedRefreshTimer = null; - if (this.isConnected && this.embedded && this.available) { - void this.browserPanelController.refreshAll(); - } - }, 0); - } - - private clearEmbeddedRefresh(): void { - if (this.embeddedRefreshTimer === null) { - return; - } - window.clearTimeout(this.embeddedRefreshTimer); - this.embeddedRefreshTimer = null; + return this.embedded ? this.presented : this.dockLayout.open; } toggle(): void { @@ -182,13 +170,12 @@ class OpenClawBrowserPanel extends OpenClawLitElement implements BrowserPanelCon } handleToggleRequest(event: Event): void { - this.clearEmbeddedRefresh(); const detail = event instanceof CustomEvent && typeof event.detail === "object" && event.detail !== null ? (event.detail as BrowserPanelToggleDetail) : null; if (this.embedded) { - if (detail?.open === false || !this.available) { + if (!this.presented || detail?.open === false || !this.available) { return; } const normalizedRequestedUrl = diff --git a/ui/src/e2e/chat-sidebar-panel-contract.e2e.test.ts b/ui/src/e2e/chat-sidebar-panel-contract.e2e.test.ts index 14c92f064472..67d21ab044b0 100644 --- a/ui/src/e2e/chat-sidebar-panel-contract.e2e.test.ts +++ b/ui/src/e2e/chat-sidebar-panel-contract.e2e.test.ts @@ -7,6 +7,7 @@ import { installMockGateway, type ControlUiMockGatewayScenario, } from "../test-helpers/control-ui-e2e.ts"; +import { openChatSidePanelType } from "./chat-side-panel.test-support.ts"; import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; const suite = createControlUiE2eSuite({ @@ -338,6 +339,109 @@ suite.define(() => { } }); + it("refreshes retained Browser state when its sidebar tab becomes active", async () => { + const context = await suite.newBrowserContext({ serviceWorkers: "block" }); + try { + const page = await context.newPage(); + await page.route("**/__openclaw__/assistant-media?*", (route) => + route.fulfill({ body: ONE_PIXEL_PNG, contentType: "image/png" }), + ); + const gateway = await installMockGateway(page, { + featureMethods: ["browser.request", "chat.metadata", "chat.startup"], + methodResponses: { + "browser.request": { + cases: [ + { + match: { method: "GET", path: "/tabs" }, + response: { running: true, tabs: [] }, + }, + ], + }, + }, + }); + + await page.goto(`${suite.server.baseUrl}chat`); + await waitForControlUiGatewayReady(page); + await openChatSidePanelType(page, "Browser"); + const browser = page.locator("openclaw-browser-panel"); + await browser.locator("openclaw-panel-empty-state").waitFor(); + + const initialRequests = await gateway.getRequests("browser.request"); + expect(initialRequests.map((request) => request.params)).toEqual([ + { method: "GET", path: "/tabs" }, + ]); + + await openChatSidePanelType(page, "Files"); + expect(await browser.evaluate((element) => element.isConnected)).toBe(true); + const hiddenRequestCount = (await gateway.getRequests("browser.request")).length; + await gateway.setMethodResponse("browser.request", { + cases: [ + { + match: { method: "GET", path: "/tabs" }, + response: { + running: true, + tabs: [ + { + targetId: "blacksmith-target", + tabId: "blacksmith-tab", + title: "Blacksmith", + url: "https://blacksmith.sh/", + }, + ], + }, + }, + { + match: { method: "POST", path: "/screenshot" }, + response: { + path: "/proof/blacksmith.png", + targetId: "blacksmith-target", + url: "https://blacksmith.sh/", + }, + }, + ], + }); + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); + expect((await gateway.getRequests("browser.request")).length).toBe(hiddenRequestCount); + + await page + .locator(".side-panel__header .tabstrip-tab") + .filter({ hasText: "Browser" }) + .click(); + await expect + .poll(async () => { + const requests = await gateway.getRequests("browser.request"); + return requests.filter((request) => { + const params = request.params as { method?: string; path?: string }; + return params.method === "GET" && params.path === "/tabs"; + }).length; + }) + .toBe(2); + await expect + .poll(async () => + (await gateway.getRequests("browser.request")).map((request) => request.params), + ) + .toContainEqual({ + body: { targetId: "blacksmith-tab", type: "png" }, + method: "POST", + path: "/screenshot", + }); + + await browser.locator(".bp-shot").waitFor(); + expect(await browser.locator(".bp-shot").getAttribute("src")).toMatch( + /^data:image\/png;base64,/, + ); + expect(await browser.locator(".bp-url").inputValue()).toBe("https://blacksmith.sh/"); + expect(await browser.locator(".bp-loading").count()).toBe(0); + } finally { + await suite.closeBrowserContext(context); + } + }); + it("preserves the production header-action shapes for Side chat and Discussion", async () => { const context = await suite.newBrowserContext({ serviceWorkers: "block" }); const page = await context.newPage(); diff --git a/ui/src/pages/chat/chat-pane-embedded-panels.ts b/ui/src/pages/chat/chat-pane-embedded-panels.ts index 01ace87c928d..7aff80877ea4 100644 --- a/ui/src/pages/chat/chat-pane-embedded-panels.ts +++ b/ui/src/pages/chat/chat-pane-embedded-panels.ts @@ -19,6 +19,7 @@ import type { SidebarSlotId } from "./sidebar-layout-types.ts"; type SidebarPanelDefinitionParams = { state: ChatPageHost; agentId: string | null; + browserPresented: boolean; desktopAvailable: boolean; hasBoard: boolean; chat: TemplateResult; @@ -100,6 +101,7 @@ export function sidebarPanelDefinitions( data-chat-autotype-exempt .client=${state.connected ? state.client : null} .available=${state.browserPanelAvailable} + .presented=${params?.browserPresented ?? false} .basePath=${state.basePath} .authToken=${resolveAssistantAttachmentAuthToken(state)} >` diff --git a/ui/src/pages/chat/chat-pane-layout-render.ts b/ui/src/pages/chat/chat-pane-layout-render.ts index 0ac5676fa18d..fa27f9493078 100644 --- a/ui/src/pages/chat/chat-pane-layout-render.ts +++ b/ui/src/pages/chat/chat-pane-layout-render.ts @@ -24,6 +24,7 @@ import { import type { SidebarFullMessageLoader } from "./components/chat-sidebar.ts"; import { SIDEBAR_NARROW_BREAKPOINT_PX, + isSidebarSlotVisible, type SidebarLayout, type SidebarSlotId, } from "./sidebar-layout.ts"; @@ -89,9 +90,12 @@ export abstract class ChatPaneLayoutRender extends ChatPaneBrowserAnnotationRend const discussion = this.buildSessionDiscussionPanel(state, state.sessionKey.trim()); const desktopAvailable = isDesktopPanelAvailable(this.context.gateway.snapshot); const companionThread = this.sessionCompanionThreads.view(state.sessionKey, currentAgentId); + const browserPresented = + this.active && this.presented && isSidebarSlotVisible(sidebarLayout, "browser"); const panelDefinitions = sidebarPanelDefinitions({ state, agentId: currentAgentId, + browserPresented, desktopAvailable, hasBoard: board.hasBoard, chat,