fix(ui): show each chat panel button once, in the pane header (#122507)

* test(ui): advertise terminal and browser panels in the mock harness

The mocked Control UI never advertised browser.request or terminal.open and
left terminalEnabled false, so the chat header's panel toggles were invisible
in the harness and could not be visually verified.

* fix(ui): give the chat pane header the only panel toggle row

The session workspace rail header rendered Terminal, Browser, Ask OpenClaw and
Changes alongside its own dock/refresh/collapse controls. Terminal and Changes
already lived in the chat pane header, so both rendered twice at once, while
Browser was reachable only from inside a files rail and Ask OpenClaw sat in a
per-session rail despite being a global surface that already owns a sidebar
entry (settings route 'custodian').

The rail header now owns workspace-file actions only. Browser moves up to the
pane header beside Terminal and into the narrow-header overflow menu; the
duplicated Terminal and Changes buttons and the Ask OpenClaw toggle are gone.

Production LOC: +21 -84.

* test(ui): stop the vite stub from shadowing the mock bootstrap config

ui/vite.config.ts registers a placeholder /control-ui-config.json middleware
and config-file plugins load before inline ones, so the mock gateway plugin's
bootstrap body never reached the app and every scenario bootstrap field was
silently dropped. Marking the mock plugin 'pre' lets it answer first.
This commit is contained in:
Peter Steinberger
2026-08-12 00:13:13 -07:00
committed by GitHub
parent d3696f8d76
commit 6f39f953ec
7 changed files with 92 additions and 104 deletions
+11 -1
View File
@@ -1472,8 +1472,10 @@ async function createChatPickerScenario(
// Advertised Gateway methods gate session actions (see
// ui/src/lib/session-method-access.ts). Omitting the mutation methods left
// every session context-menu row disabled, so the harness could not show
// the menu operators actually see.
// the menu operators actually see. browser.request/terminal.open likewise
// gate the chat header's panel toggles, which stayed invisible here.
featureMethods: [
"browser.request",
"chat.metadata",
"chat.startup",
"question.list",
@@ -1493,7 +1495,11 @@ async function createChatPickerScenario(
"sessions.catalog.list",
"sessions.catalog.read",
"system.info",
"terminal.open",
],
// Terminal has a second gate beyond the advertised method (see
// ui/src/lib/terminal-availability.ts).
terminalEnabled: true,
historyMessages: buildScrollableChatHistory(baseTime),
// Lights up the footer facepile and who's-online roster; the email-only
// entry keeps the roster's no-display-name row exercised.
@@ -2436,6 +2442,10 @@ function createMockGatewayPlugin(scenario: ControlUiMockGatewayScenario): Plugin
res.end(bootstrapBody);
});
},
// ui/vite.config.ts registers a placeholder bootstrap-config middleware and
// config-file plugins load first, so without "pre" its stub answers every
// request and the scenario's bootstrap fields never reach the app.
enforce: "pre",
name: "openclaw-control-ui-mock-gateway",
transformIndexHtml(html) {
return html.replace(
+21 -1
View File
@@ -186,6 +186,18 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu {
detail: { open: true },
}),
);
const browserPanelAction = sessionWorkspace.onToggleBrowser
? html`<openclaw-tooltip .content=${t("browser.toggle")}>
<button
class="btn btn--ghost btn--icon chat-icon-btn chat-browser-panel-toggle"
type="button"
aria-label=${t("browser.toggle")}
@click=${sessionWorkspace.onToggleBrowser}
>
${icons.globe}
</button>
</openclaw-tooltip>`
: nothing;
const desktopPanelAction = desktopPanelAvailable
? html`<openclaw-tooltip .content=${t("desktop.toggle")}>
<button
@@ -210,6 +222,14 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu {
onActivate: sessionWorkspace.onToggleTerminal,
});
}
if (sessionWorkspace.onToggleBrowser) {
panelMenuActions.push({
id: "browser",
label: t("browser.toggle"),
icon: icons.globe,
onActivate: sessionWorkspace.onToggleBrowser,
});
}
if (desktopPanelAvailable) {
panelMenuActions.push({
id: "desktop",
@@ -324,7 +344,7 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu {
this.state,
this.catalogSession,
sessionWorkspace.onToggleTerminal,
)}${desktopPanelAction}`,
)}${browserPanelAction}${desktopPanelAction}`,
discussionAction: this.renderSessionDiscussionAction(discussion),
diffAction: renderSessionDiffToggle(sessionWorkspace),
backgroundTasksAction: renderBackgroundTasksToggle(backgroundTasks),
@@ -5,8 +5,10 @@ import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient, GatewayHelloOk } from "../../api/gateway.ts";
import type { GatewaySessionRow } from "../../api/types.ts";
import {
BROWSER_PANEL_TOGGLE_EVENT,
DESKTOP_PANEL_TOGGLE_EVENT,
TERMINAL_PANEL_TOGGLE_EVENT,
type BrowserPanelToggleDetail,
type DesktopPanelToggleDetail,
type TerminalPanelToggleDetail,
} from "../../components/panel-toggle-contract.ts";
@@ -129,4 +131,57 @@ describe("chat pane terminal action", () => {
window.removeEventListener(DESKTOP_PANEL_TOGGLE_EVENT, listener);
}
});
it("renders the browser control only when available and exposes it in the narrow menu", () => {
const client = { request: vi.fn() } as unknown as GatewayBrowserClient;
const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability });
const session = {
key: state.sessionKey,
kind: "direct",
updatedAt: 0,
} satisfies GatewaySessionRow;
const container = document.createElement("div");
const renderHeader = () =>
render(
pane.renderPaneHeader(
createSessionWorkspaceProps(state),
createBackgroundTasksProps(state),
session,
false,
undefined,
false,
),
container,
);
const panelActionIds = () =>
container
.querySelector<HTMLElement & { panelActions: Array<{ id: string }> }>(
"openclaw-chat-header-session-menu",
)
?.panelActions.map((action) => action.id) ?? [];
state.browserPanelAvailable = false;
renderHeader();
expect(container.querySelector(".chat-browser-panel-toggle")).toBeNull();
expect(panelActionIds()).not.toContain("browser");
const events: CustomEvent<BrowserPanelToggleDetail>[] = [];
const listener = (event: Event) => events.push(event as CustomEvent<BrowserPanelToggleDetail>);
window.addEventListener(BROWSER_PANEL_TOGGLE_EVENT, listener);
try {
state.browserPanelAvailable = true;
renderHeader();
const button = container.querySelector<HTMLButtonElement>(".chat-browser-panel-toggle");
expect(button).not.toBeNull();
button?.click();
expect(events).toHaveLength(1);
(pane as typeof pane & { narrow: boolean }).narrow = true;
renderHeader();
expect(container.querySelector(".chat-browser-panel-toggle")).toBeNull();
expect(panelActionIds()).toContain("browser");
} finally {
window.removeEventListener(BROWSER_PANEL_TOGGLE_EVENT, listener);
}
});
});
+5
View File
@@ -1793,6 +1793,9 @@ describe("chat composer workbench", () => {
const container = renderChatView({
sessionWorkspace: createSessionWorkspace({
narrowLayout: true,
onToggleTerminal: vi.fn(),
onToggleBrowser: vi.fn(),
onOpenDiff: vi.fn(),
}),
});
@@ -1801,6 +1804,8 @@ describe("chat composer workbench", () => {
expect(container.querySelector(".chat-workspace-rail")).not.toBeNull();
expect(container.querySelector(".chat-workspace-rail__dock")).toBeNull();
expect(container.querySelector(".chat-workspace-rail__grip")).toBeNull();
expect(container.querySelector(".chat-workspace-rail__terminal")).toBeNull();
expect(container.querySelector(".chat-session-diff-toggle")).toBeNull();
});
it("moves the background-tasks rail to a bottom strip on narrow panes", () => {
@@ -43,25 +43,6 @@ describe("toggleSessionWorkspace", () => {
});
});
describe("custodian panel toggle", () => {
it("is available only while the gateway is connected and advertises chat", () => {
const state = {
client: null,
connected: false,
handleOpenSidebar: vi.fn(),
hello: gatewayHello(["openclaw.chat"]),
requestUpdate: vi.fn(),
sessionKey: "agent:main:current",
sessions: {},
} as unknown as SessionWorkspaceHost;
expect(createSessionWorkspaceProps(state).onToggleCustodian).toBeUndefined();
state.connected = true;
expect(createSessionWorkspaceProps(state).onToggleCustodian).toBeTypeOf("function");
});
});
describe("session workspace artifacts", () => {
function createArtifactHost(params: { data: string; mimeType: string; title?: string }) {
const handleOpenSidebar = vi.fn();
@@ -20,7 +20,6 @@ import {
import { icons } from "../../../components/icons.ts";
import {
BROWSER_PANEL_TOGGLE_EVENT,
CUSTODIAN_PANEL_TOGGLE_EVENT,
TERMINAL_PANEL_TOGGLE_EVENT,
} from "../../../components/panel-toggle-contract.ts";
import "../../../components/tooltip.ts";
@@ -65,7 +64,6 @@ export type SessionWorkspaceProps = {
onOpenArtifact: (artifactId: string) => void;
onToggleTerminal?: () => void;
onToggleBrowser?: () => void;
onToggleCustodian?: () => void;
/** Opens the session diff panel; absent until a usable checkout is known. */
onOpenDiff?: () => void;
};
@@ -828,10 +826,6 @@ export function createSessionWorkspaceProps(
window.dispatchEvent(new CustomEvent(BROWSER_PANEL_TOGGLE_EVENT, {}));
}
: undefined,
onToggleCustodian:
state.connected && isGatewayMethodAdvertised(state, "openclaw.chat") === true
? () => window.dispatchEvent(new CustomEvent(CUSTODIAN_PANEL_TOGGLE_EVENT))
: undefined,
onOpenDiff: canOpenDiff
? () => state.handleOpenSidebar(buildSessionDiffSidebarContent(state))
: undefined,
@@ -961,62 +955,6 @@ export function renderSessionWorkspaceRail(
// Narrow panes always present the rail as a bottom strip; a side column
// would crush the thread below its readable minimum.
const dock = sessionWorkspace.narrowLayout ? "bottom" : sessionWorkspace.dock;
const terminalButton = sessionWorkspace.onToggleTerminal
? html`
<openclaw-tooltip .content=${t("terminal.toggle")}>
<button
type="button"
class="chat-workspace-rail__terminal"
aria-label=${t("terminal.toggle")}
@click=${sessionWorkspace.onToggleTerminal}
>
${icons.terminal}
</button>
</openclaw-tooltip>
`
: nothing;
const browserButton = sessionWorkspace.onToggleBrowser
? html`
<openclaw-tooltip .content=${t("browser.toggle")}>
<button
type="button"
class="chat-workspace-rail__terminal"
aria-label=${t("browser.toggle")}
@click=${sessionWorkspace.onToggleBrowser}
>
${icons.globe}
</button>
</openclaw-tooltip>
`
: nothing;
const custodianButton = sessionWorkspace.onToggleCustodian
? html`
<openclaw-tooltip .content=${t("custodian.panel.toggle")}>
<button
type="button"
class="chat-workspace-rail__terminal"
aria-label=${t("custodian.panel.toggle")}
@click=${sessionWorkspace.onToggleCustodian}
>
${icons.lobster}
</button>
</openclaw-tooltip>
`
: nothing;
const diffButton = sessionWorkspace.onOpenDiff
? html`
<openclaw-tooltip .content=${t("chat.sessionDiff.show")}>
<button
type="button"
class="chat-workspace-rail__terminal chat-session-diff-toggle"
aria-label=${t("chat.sessionDiff.show")}
@click=${sessionWorkspace.onOpenDiff}
>
${icons.diff}
</button>
</openclaw-tooltip>
`
: nothing;
const files = sessionWorkspace.list?.files ?? [];
const modifiedFiles = files.filter((file) => file.kind === "modified");
const readFiles = files.filter((file) => file.kind === "read");
@@ -1306,7 +1244,6 @@ export function renderSessionWorkspaceRail(
<strong>${t("chat.workspaceFiles.files")}</strong>
</div>
<div class="chat-workspace-rail__actions">
${diffButton} ${terminalButton} ${browserButton} ${custodianButton}
${sessionWorkspace.narrowLayout
? nothing
: html`
-20
View File
@@ -454,7 +454,6 @@ openclaw-chat-sidebar-region,
gap: 6px;
}
.chat-workspace-rail__terminal,
.chat-workspace-rail__refresh,
.chat-workspace-rail__dock,
.chat-workspace-rail__collapse-toggle {
@@ -476,24 +475,6 @@ openclaw-chat-sidebar-region,
cursor: grabbing;
}
.chat-workspace-rail__terminal {
display: inline-flex;
align-items: center;
justify-content: center;
/* Chromeless at rest like the sibling ghost buttons; chrome on hover only. */
border: 1px solid transparent;
border-radius: var(--radius-md);
background: transparent;
color: var(--muted);
}
.chat-workspace-rail__terminal:hover,
.chat-workspace-rail__terminal:focus-visible {
color: var(--text);
border-color: color-mix(in srgb, var(--accent) 34%, var(--border));
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
/* Overrides the shared .nav-collapse-toggle chrome (border, elevated
background, inset highlight): rail header buttons only show chrome on
hover. */
@@ -510,7 +491,6 @@ openclaw-chat-sidebar-region,
transform: none;
}
.chat-workspace-rail__terminal svg,
.chat-workspace-rail__refresh svg,
.chat-workspace-rail__dock svg,
.chat-workspace-rail__collapse-toggle svg,