mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
d3696f8d76
commit
6f39f953ec
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user