fix(ui): gate chat sidebar mutations (#113947)

This commit is contained in:
Peter Steinberger
2026-07-25 19:01:32 -07:00
committed by GitHub
parent 25495ad0be
commit c7fcfd3482
5 changed files with 86 additions and 18 deletions
+3
View File
@@ -668,6 +668,9 @@ export class ChatPaneRender extends ChatPaneHeaderRender {
focusVersion: state.sidebarFocusVersion,
layout: sidebarLayout,
narrow: this.paneWidth < SIDEBAR_NARROW_BREAKPOINT_PX,
panelMutationEnabled: {
chat: Boolean(board.activeTabId) && !board.activeTabReadOnly && board.provider.canMutate,
},
panelTemplates,
primary,
sessionKey: state.sessionKey,
@@ -47,6 +47,7 @@ describe("chat pane sidebar layout", () => {
focusVersion: 0,
layout,
narrow,
panelMutationEnabled: {},
panelTemplates: { detail: html`<aside>Details</aside>` },
primary: html`<main data-primary>Primary</main>`,
sessionKey: "agent:main:current",
@@ -107,6 +108,7 @@ describe("chat pane sidebar layout", () => {
focusVersion: 0,
layout: openSlot({ columns: [] }, "detail"),
narrow: false,
panelMutationEnabled: {},
panelTemplates: { detail: html`<aside>Details</aside>` },
primary: html`<main>Primary</main>`,
sessionKey: "agent:main:current",
@@ -18,6 +18,7 @@ import {
type SidebarColumn,
type SidebarLayout,
type SidebarPanel,
type SidebarSlotId,
} from "./sidebar-layout.ts";
const DETAIL_FULL_MESSAGE_MAX_CHARS = 500_000;
@@ -31,6 +32,7 @@ export function renderSidebarRegion(params: {
focusVersion: number;
layout: SidebarLayout;
narrow: boolean;
panelMutationEnabled: Partial<Record<SidebarSlotId, boolean>>;
panelTemplates: SidebarPanelTemplates;
primary: TemplateResult;
sessionKey: string;
@@ -53,6 +55,7 @@ export function renderSidebarRegion(params: {
.layout=${params.layout}
.panelTemplates=${params.panelTemplates}
.panelOpenUrls=${{ discussion: params.discussionOpenUrl }}
.panelMutationEnabled=${params.panelMutationEnabled}
.callbacks=${params.callbacks}
.sessionKey=${params.sessionKey}
.focusPanelId=${params.focusPanelId}
@@ -39,6 +39,8 @@ class ChatSidebarRegion extends OpenClawLightDomElement {
@property({ attribute: false }) layout: SidebarLayout = { columns: [] };
@property({ attribute: false }) panelTemplates: SidebarPanelTemplates = {};
@property({ attribute: false }) panelOpenUrls: Partial<Record<SidebarSlotId, string | null>> = {};
@property({ attribute: false }) panelMutationEnabled: Partial<Record<SidebarSlotId, boolean>> =
{};
@property({ attribute: false }) callbacks: SidebarRegionCallbacks | null = null;
@property() sessionKey = "";
@property() focusPanelId = "";
@@ -122,6 +124,10 @@ class ChatSidebarRegion extends OpenClawLightDomElement {
this.callbacks?.activatePanel(panelId);
}
private canMutatePanel(slot: SidebarSlotId): boolean {
return this.panelMutationEnabled[slot] !== false;
}
private renderHeader(column: SidebarColumn, activePanelId: string, narrow: boolean) {
const active = column.panels.find((panel) => panel.id === activePanelId) ?? column.panels[0];
if (!active) {
@@ -141,22 +147,25 @@ class ChatSidebarRegion extends OpenClawLightDomElement {
without-scroll-controls
@wa-tab-show=${(event: CustomEvent<{ name: string }>) => this.activate(event.detail.name)}
>
${column.panels.map(
(panel) => html`
${column.panels.map((panel) => {
const draggable = !narrow && this.canMutatePanel(panel.slot);
return html`
<wa-tab
class="sidebar-column__tab"
panel=${panel.id}
data-panel-id=${panel.id}
.draggable=${!narrow}
title=${t("chat.sidebarColumns.drag", { panel: panelTitle(panel.slot) })}
.draggable=${draggable}
title=${draggable
? t("chat.sidebarColumns.drag", { panel: panelTitle(panel.slot) })
: nothing}
@dragstart=${(event: DragEvent) =>
narrow ? undefined : this.startDrag(event, panel.id)}
draggable ? this.startDrag(event, panel.id) : undefined}
@dragend=${() => this.endDrag()}
>
${panelTitle(panel.slot)}
</wa-tab>
`,
)}
`;
})}
</wa-tab-group>
<div class="sidebar-column__actions">
${openUrl
@@ -170,15 +179,17 @@ class ChatSidebarRegion extends OpenClawLightDomElement {
>${icons.externalLink}</a
>`
: nothing}
<button
class="btn btn--ghost btn--icon"
type="button"
aria-label=${t("chat.sidebarColumns.close", { panel: panelTitle(active.slot) })}
title=${t("chat.sidebarColumns.close", { panel: panelTitle(active.slot) })}
@click=${() => this.callbacks?.closeSlot(active.slot)}
>
${icons.x}
</button>
${this.canMutatePanel(active.slot)
? html`<button
class="btn btn--ghost btn--icon"
type="button"
aria-label=${t("chat.sidebarColumns.close", { panel: panelTitle(active.slot) })}
title=${t("chat.sidebarColumns.close", { panel: panelTitle(active.slot) })}
@click=${() => this.callbacks?.closeSlot(active.slot)}
>
${icons.x}
</button>`
: nothing}
</div>
</div>
`;
@@ -3,7 +3,7 @@
import { html } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import "../../../components/resizable-divider.ts";
import { mergePanelIntoColumn, openSlot } from "../sidebar-layout.ts";
import { mergePanelIntoColumn, openSlot, type SidebarSlotId } from "../sidebar-layout.ts";
import "./chat-sidebar-region.runtime.ts";
type Region = HTMLElementTagNameMap["openclaw-chat-sidebar-region"] & {
@@ -12,7 +12,10 @@ type Region = HTMLElementTagNameMap["openclaw-chat-sidebar-region"] & {
const regions: Region[] = [];
async function createRegion(narrow: boolean) {
async function createRegion(
narrow: boolean,
panelMutationEnabled: Partial<Record<SidebarSlotId, boolean>> = {},
) {
const shell = document.createElement("div");
shell.className = `sidebar-region ${narrow ? "sidebar-region--narrow" : ""}`;
const region = document.createElement("openclaw-chat-sidebar-region") as Region;
@@ -22,6 +25,7 @@ async function createRegion(narrow: boolean) {
detail: html`<div data-panel="detail">Detail panel</div>`,
discussion: html`<div data-panel="discussion">Discussion panel</div>`,
};
region.panelMutationEnabled = panelMutationEnabled;
region.callbacks = {
activatePanel: vi.fn(),
closeSlot: vi.fn(),
@@ -144,6 +148,51 @@ describe("chat sidebar region", () => {
);
});
it("gates chat close while keeping non-chat close controls functional", async () => {
const region = await createRegion(false, { chat: false });
const closeButtons = () =>
Array.from(
regionRoot(region).querySelectorAll<HTMLButtonElement>(".sidebar-column__actions button"),
);
const closeButton = (panel: string) =>
closeButtons().find((button) => button.getAttribute("aria-label") === `Close ${panel}`);
expect(closeButton("Chat")).toBeUndefined();
closeButton("Details")?.click();
closeButton("Discussion")?.click();
expect(region.callbacks?.closeSlot).toHaveBeenCalledWith("detail");
expect(region.callbacks?.closeSlot).toHaveBeenCalledWith("discussion");
region.panelMutationEnabled = { chat: true };
await region.updateComplete;
closeButton("Chat")?.click();
closeButton("Details")?.click();
closeButton("Discussion")?.click();
expect(region.callbacks?.closeSlot).toHaveBeenCalledWith("chat");
expect(region.callbacks?.closeSlot).toHaveBeenCalledWith("detail");
expect(region.callbacks?.closeSlot).toHaveBeenCalledWith("discussion");
});
it("gates chat dragging while keeping non-chat panels draggable", async () => {
const region = await createRegion(false, { chat: false });
const tabs = () =>
Array.from(regionRoot(region).querySelectorAll<HTMLElement>(".sidebar-column__tab"));
const tab = (panel: string) =>
tabs().find((candidate) => candidate.textContent?.trim() === panel);
expect(tab("Chat")?.draggable).toBe(false);
expect(tab("Details")?.draggable).toBe(true);
expect(tab("Discussion")?.draggable).toBe(true);
region.panelMutationEnabled = { chat: true };
await region.updateComplete;
expect(tab("Chat")?.draggable).toBe(true);
expect(tab("Details")?.draggable).toBe(true);
expect(tab("Discussion")?.draggable).toBe(true);
});
it("routes boundary drops through the detach callback", async () => {
const region = await createRegion(false);
const source = regionRoot(region).querySelectorAll<HTMLElement>(".sidebar-column__tab")[1]!;