fix(ui): resize empty chat side panel (#130366)

* fix(ui): resize empty side panel

* test(ui): preserve empty sidebar geometry expectations
This commit is contained in:
Vyctor H. Brzezowski
2026-08-26 18:06:47 -03:00
committed by GitHub
parent a2051ff3a9
commit f290d6bc81
9 changed files with 121 additions and 48 deletions
+41
View File
@@ -18,6 +18,7 @@ const suite = createControlUiE2eSuite({
const sessionKey = "agent:main:rail-tabs";
const proofDir = process.env.OPENCLAW_UI_RAIL_PROOF_DIR?.trim();
const videoDir = process.env.OPENCLAW_UI_RAIL_VIDEO_DIR?.trim();
const historyMessages = Array.from({ length: 10 }, (_, index) => ({
id: `rail-tabs-${index}`,
@@ -362,6 +363,9 @@ suite.define(() => {
{
colorScheme: themeMode,
locale: "en-US",
...(videoDir && themeMode === "light"
? { recordVideo: { dir: videoDir, size: { height: 900, width: 1600 } } }
: {}),
serviceWorkers: "block",
viewport: { height: 900, width: 1600 },
},
@@ -822,7 +826,44 @@ suite.define(() => {
await page.locator(".chat-side-panel-toggle").click();
await sidePanel(page).locator(".side-panel-empty--selector").waitFor();
expect(await sidePanel(page).locator("wa-tab").count()).toBe(0);
const emptyDividerBox = await divider.boundingBox();
expect(emptyDividerBox).not.toBeNull();
await page.mouse.move(
emptyDividerBox!.x + 1,
emptyDividerBox!.y + emptyDividerBox!.height / 2,
);
await page.mouse.down();
await page.mouse.move(
emptyDividerBox!.x - 70,
emptyDividerBox!.y + emptyDividerBox!.height / 2,
);
await page.mouse.up();
await expect
.poll(() =>
sidePanel(page).evaluate((element) => element.getBoundingClientRect().width),
)
.toBeGreaterThan(resizedWidth + 50);
const emptyResizedWidth = await sidePanel(page).evaluate(
(element) => element.getBoundingClientRect().width,
);
await divider.evaluate((element) => element.blur());
await captureRichPanel(page, `rails-tabs-empty-resized-${themeMode}`);
await page.reload();
await page.locator(".chat-group").first().waitFor();
await sidePanel(page).locator(".side-panel-empty--selector").waitFor();
expect(await divider.boundingBox()).not.toBeNull();
await expect
.poll(() =>
sidePanel(page).evaluate((element) => element.getBoundingClientRect().width),
)
.toBeCloseTo(emptyResizedWidth, 0);
await openFromEmpty(page, "Terminal");
await expect
.poll(() =>
sidePanel(page).evaluate((element) => element.getBoundingClientRect().width),
)
.toBeCloseTo(emptyResizedWidth, 0);
const terminalLabel = sidePanel(page)
.locator(sidePanelTabLabelSelector)
.filter({ hasText: "Terminal" });
@@ -204,7 +204,10 @@ describe("chat pane sidebar layout", () => {
layout: { ...openSlot({ columns: [] }, "chat"), open },
paneWidth: 1_400,
});
expect(empty).toMatchObject({ columns: [], open: false });
expect(empty).toMatchObject({
columns: [{ panels: [], activePanelId: "", height: 360, width: 480 }],
open: false,
});
const withDetail = resolveSidebarLayoutForBoard({
board: board("hidden", "chat"),
@@ -134,7 +134,8 @@ describe("chat pane session discussion", () => {
expect(action?.ariaLabel).toBe("Hide discussion");
expect(action?.getAttribute("aria-pressed")).toBe("true");
action?.click();
expect(state.sidebarLayout.columns).toEqual([]);
expect(state.sidebarLayout.columns[0]?.panels).toEqual([]);
expect(state.sidebarLayout.open).toBe(false);
expect(updateSidebarLayout).toHaveBeenCalledTimes(2);
container.remove();
+1 -1
View File
@@ -921,7 +921,7 @@ describe("chat pane keyboard shortcuts", () => {
expect(press().defaultPrevented).toBe(true);
expect(state.sidebarLayout.columns[0]?.panels.map((panel) => panel.slot)).toEqual(["terminal"]);
expect(press().defaultPrevented).toBe(true);
expect(state.sidebarLayout.columns).toEqual([]);
expect(state.sidebarLayout.columns[0]?.panels).toEqual([]);
expect(state.sidebarLayout.open).toBe(false);
});
});
@@ -275,7 +275,7 @@ class ChatSidebarRegion extends OpenClawLightDomElement {
}
private renderBody(column?: SidebarColumn) {
if (!column) {
if (!column || column.panels.length === 0) {
return html`<div id="chat-side-panel-content" class="side-panel__body">
${this.renderEmpty()}
</div>`;
@@ -359,7 +359,7 @@ class ChatSidebarRegion extends OpenClawLightDomElement {
style=${styleMap({ width, height })}
aria-label=${t("chat.sidePanel.label")}
>
${column
${column?.panels.length
? this.renderHeader(column)
: html`<header class="rail-header side-panel__header side-panel__header--empty">
<strong class="side-panel__empty-header-title">${t("chat.sidePanel.label")}</strong>
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import "../../../components/resizable-divider.ts";
import {
openSlot,
setSidebarOpen,
setSidebarDock,
setSidebarExpanded,
type SidebarLayout,
@@ -219,7 +220,7 @@ describe("chat sidebar region", () => {
});
it("opens into a type selector instead of restoring a previous tab", async () => {
const region = await createRegion({ columns: [], open: true, expanded: false });
const region = await createRegion(setSidebarOpen({ columns: [], expanded: false }, true));
const selector = root(region).querySelector(".side-panel-empty--selector");
expect(selector?.querySelector(".side-panel-empty__title")?.textContent).toBe("Open a tab");
@@ -321,8 +322,9 @@ describe("chat sidebar region", () => {
});
it("offers expand and minimize controls in the no-tabs selector", async () => {
const region = await createRegion({ columns: [], open: true });
const region = await createRegion(setSidebarOpen({ columns: [] }, true));
expect(root(region).querySelector<HTMLElement>(".side-panel")?.style.width).toBe("480px");
expect(root(region).querySelector("resizable-divider")).not.toBeNull();
root(region).querySelector<HTMLButtonElement>(".side-panel__expand")?.click();
root(region).querySelector<HTMLButtonElement>(".side-panel__minimize")?.click();
expect(region.callbacks?.setExpanded).toHaveBeenCalledWith(true);
+15 -17
View File
@@ -76,9 +76,6 @@ export function normalizeSidebarLayout(value: unknown): SidebarLayout {
usedSlots.add(rawPanel.slot);
columnPanels.push({ id: panelId, slot: rawPanel.slot });
}
if (columnPanels.length === 0) {
continue;
}
const requestedActiveId =
typeof rawColumn.activePanelId === "string" ? rawColumn.activePanelId.trim() : "";
activePanelId = panelIds.get(requestedActiveId) ?? activePanelId;
@@ -92,20 +89,21 @@ export function normalizeSidebarLayout(value: unknown): SidebarLayout {
: height;
panels.push(...columnPanels);
}
const columns = panels.length
? [
{
id: usedColumnIds.values().next().value ?? "side-panel-column",
side: "right" as const,
panels,
activePanelId: panels.some((panel) => panel.id === activePanelId)
? activePanelId
: panels[0]!.id,
height,
width,
},
]
: [];
const columns =
usedColumnIds.size > 0 || value.open === true
? [
{
id: usedColumnIds.values().next().value ?? "side-panel-column",
side: "right" as const,
panels,
activePanelId: panels.some((panel) => panel.id === activePanelId)
? activePanelId
: (panels[0]?.id ?? ""),
height,
width,
},
]
: [];
return {
columns,
dock: value.dock === "bottom" ? "bottom" : "right",
+28 -1
View File
@@ -73,7 +73,19 @@ describe("sidebar layout", () => {
it("collapses the panel after its final tab closes", () => {
const closed = closeSlot(openSlot({ columns: [] }, "detail"), "detail");
expect(closed).toEqual({ columns: [], open: false });
expect(closed).toEqual({
columns: [
{
id: "side-panel-column",
side: "right",
panels: [],
activePanelId: "",
height: 360,
width: 480,
},
],
open: false,
});
});
it("does not reopen a minimized panel when another tab remains", () => {
@@ -160,6 +172,21 @@ describe("sidebar layout", () => {
it("deduplicates slots and repairs untrusted persisted values", () => {
expect(normalizeSidebarLayout(null)).toEqual({ columns: [], open: false, expanded: false });
expect(normalizeSidebarLayout({ columns: [], open: true })).toEqual({
columns: [
{
id: "side-panel-column",
side: "right",
panels: [],
activePanelId: "",
height: 360,
width: 480,
},
],
dock: "right",
open: true,
expanded: false,
});
expect(
normalizeSidebarLayout({
columns: [
+24 -23
View File
@@ -1,4 +1,5 @@
import type {
SidebarColumn,
SidebarDock,
SidebarLayout,
SidebarPanel,
@@ -28,6 +29,17 @@ function cloneLayout(layout: SidebarLayout): SidebarLayout {
return structuredClone(layout);
}
function createSidebarColumn(): SidebarColumn {
return {
id: "side-panel-column",
side: "right",
panels: [],
activePanelId: "",
height: SIDEBAR_DEFAULT_HEIGHT_PX,
width: SIDEBAR_DEFAULT_WIDTH_PX,
};
}
function clampWidth(width: number): number {
return Math.min(SIDEBAR_MAX_WIDTH_PX, Math.max(SIDEBAR_MIN_WIDTH_PX, width));
}
@@ -63,16 +75,13 @@ function nextPanelId(layout: SidebarLayout, slot: SidebarSlotId): string {
}
function removePanel(layout: SidebarLayout, panelId: string): SidebarPanel | null {
for (let columnIndex = 0; columnIndex < layout.columns.length; columnIndex += 1) {
const column = layout.columns[columnIndex]!;
for (const column of layout.columns) {
const panelIndex = column.panels.findIndex((panel) => panel.id === panelId);
if (panelIndex < 0) {
continue;
}
const panel = column.panels.splice(panelIndex, 1)[0]!;
if (column.panels.length === 0) {
layout.columns.splice(columnIndex, 1);
} else if (column.activePanelId === panelId) {
if (column.activePanelId === panelId) {
column.activePanelId =
column.panels[Math.min(panelIndex, column.panels.length - 1)]?.id ?? "";
}
@@ -95,22 +104,9 @@ export function openSlot(layout: SidebarLayout, slot: SidebarSlotId): SidebarLay
return next;
}
const panel: SidebarPanel = { id: nextPanelId(next, slot), slot };
const column = next.columns[0];
if (column) {
column.panels.push(panel);
column.activePanelId = panel.id;
} else {
next.columns = [
{
id: "side-panel-column",
side: "right",
panels: [panel],
activePanelId: panel.id,
height: SIDEBAR_DEFAULT_HEIGHT_PX,
width: SIDEBAR_DEFAULT_WIDTH_PX,
},
];
}
const column = (next.columns[0] ??= createSidebarColumn());
column.panels.push(panel);
column.activePanelId = panel.id;
next.open = true;
return next;
}
@@ -122,7 +118,7 @@ export function closeSlot(layout: SidebarLayout, slot: SidebarSlotId): SidebarLa
.find((entry) => entry.slot === slot);
if (panel) {
removePanel(next, panel.id);
if (next.columns.length === 0) {
if (next.columns.every((column) => column.panels.length === 0)) {
next.open = false;
}
}
@@ -162,7 +158,12 @@ export function reorderPanel(
}
export function setSidebarOpen(layout: SidebarLayout, open: boolean): SidebarLayout {
return { ...cloneLayout(layout), open };
const next = cloneLayout(layout);
if (open) {
next.columns[0] ??= createSidebarColumn();
}
next.open = open;
return next;
}
export function setSidebarExpanded(layout: SidebarLayout, expanded: boolean): SidebarLayout {