fix(ui): refresh Browser when sidebar tab activates (#126058)

This commit is contained in:
Peter Steinberger
2026-08-18 19:05:20 -07:00
committed by GitHub
parent a8bc60a119
commit d1f327a4e4
6 changed files with 144 additions and 36 deletions
@@ -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);
@@ -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<unknown>;
};
@@ -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<unknown>;
};
panel.available = true;
panel.embedded = true;
panel.presented = true;
document.body.append(panel);
await panel.updateComplete;
+22 -35
View File
@@ -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 =
@@ -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<void>((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();
@@ -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)}
></openclaw-browser-panel>`
@@ -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,