refactor(ui): make chat rails full-height resizable columns (#122475)

* refactor(ui): make chat rails resizable columns

* fix(ui): preserve board runtime across rail modes

* fix(ui): unify chat rail chrome

* fix(ui): unify rail header controls

* refactor(ui): normalize rail header chrome

* fix(ui): render background tasks rail

* test(ui): preserve workspace collapse semantics

* fix(ui): keep companion separator single

* test(ui): distinguish companion sheet and column borders

* test(ui): compare shared rail focus chrome

* test(ui): drive rail focus with keyboard

* fix(ui): align single-panel rail headers

* fix(ui): add custodian rail toggle label

* test(ui): narrow mock chat request params

* test(ui): follow shared rail controls

* test(ui): keep rich rail state visible

* fix(ui): center terminal rail actions

* test(ui): keep chat notices in conversation column

* test(ui): detect focus inside rail shadow roots

* test(ui): respect desktop overlay interactions

* test(ui): restore rails after route navigation

* test(ui): rebuild route-scoped rail matrix

* test(ui): preserve custodian rail during matrix

* chore(ui): keep chat pane within lint budget

* test(ui): tolerate subpixel icon geometry
This commit is contained in:
Vyctor H. Brzezowski
2026-08-13 19:49:11 -03:00
committed by GitHub
parent 4137c58c97
commit f73b436f05
39 changed files with 1909 additions and 678 deletions
+12
View File
@@ -819,6 +819,18 @@ describe("loadSettings default gateway URL derivation", () => {
expect(loadSettings().chatSplitLayout).toEqual(chatSplitLayout);
});
it("preserves an opted-in bottom workspace dock", () => {
setTestLocation({
protocol: "https:",
host: "gateway.example:8443",
pathname: "/",
});
saveSettings({ ...loadSettings(), chatWorkspaceDock: "bottom" });
expect(loadSettings().chatWorkspaceDock).toBe("bottom");
});
it("persists dashboard tab and dock state per session", () => {
setTestLocation({
protocol: "https:",
@@ -1,16 +1,14 @@
import { html, nothing, svg, type TemplateResult } from "lit";
import { t } from "../../i18n/index.ts";
import { openExternalUrlSafe } from "../../lib/open-external-url.ts";
import { icons } from "../icons.ts";
import type { BrowserPanelController } from "./browser-panel-controller.ts";
import { renderBrowserPanelTabs } from "./browser-panel-tabs.ts";
const CLOSE_GLYPH = svg`<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>`;
const DOCK_BOTTOM_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M2 10h12" /></svg>`;
const DOCK_RIGHT_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M10 2.5v11" /></svg>`;
const BACK_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 3L5 8l5 5" /></svg>`;
const FORWARD_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3l5 5-5 5" /></svg>`;
const RELOAD_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M13 8a5 5 0 1 1-1.5-3.6M13 2.5V5h-2.5" /></svg>`;
const EXTERNAL_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5H3.5v9h9V9.5M9.5 3h3.5v3.5M12.8 3.2L7.5 8.5" /></svg>`;
const PENCIL_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M11.3 2.7l2 2L5 13H3v-2z" /></svg>`;
const INSPECT_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3l5.5 10 1.2-4.3L14 7.5z" /></svg>`;
@@ -34,27 +32,27 @@ function renderHeaderActions(
) {
const activeUrl = controller.view?.metrics?.url || controller.view?.url || controller.urlDraft;
return html`
<div class="bp-actions">
<div class="rail-header__actions bp-actions">
<button
class="bp-icon ${dock === "bottom" ? "is-active" : ""}"
class="rail-header__action bp-icon ${dock === "bottom" ? "is-active" : ""}"
type="button"
title=${t("browser.dockBottom")}
aria-label=${t("browser.dockBottom")}
@click=${() => onDockChange("bottom")}
>
${DOCK_BOTTOM_GLYPH}
${icons.panelBottomOpen}
</button>
<button
class="bp-icon ${dock === "right" ? "is-active" : ""}"
class="rail-header__action bp-icon ${dock === "right" ? "is-active" : ""}"
type="button"
title=${t("browser.dockRight")}
aria-label=${t("browser.dockRight")}
@click=${() => onDockChange("right")}
>
${DOCK_RIGHT_GLYPH}
${icons.panelRightOpen}
</button>
<button
class="bp-icon"
class="rail-header__action bp-icon"
type="button"
title=${t("browser.openExternal")}
aria-label=${t("browser.openExternal")}
@@ -65,16 +63,16 @@ function renderHeaderActions(
}
}}
>
${EXTERNAL_GLYPH}
${icons.externalLink}
</button>
<button
class="bp-icon"
class="rail-header__action bp-icon"
type="button"
title=${t("browser.hide")}
aria-label=${t("browser.hide")}
title=${t("browser.close")}
aria-label=${t("browser.close")}
@click=${onClose}
>
${CLOSE_GLYPH}
${icons.x}
</button>
</div>
`;
@@ -327,7 +325,7 @@ export function renderBrowserPanelChrome(
return html`
<section class="bp bp--${dock}" style=${style} aria-label=${t("browser.title")}>
${resizer}
<header class="bp-header">
<header class="rail-header bp-header">
${renderTabStrip(controller)}
${renderHeaderActions(controller, dock, onDockChange, onClose)}
</header>
@@ -17,13 +17,6 @@ export const browserPanelStyles = css`
right: var(--oc-terminal-reserve-right, 0px);
bottom: var(--oc-terminal-reserve-bottom, 0px);
}
.bp-icon.is-active {
color: var(--accent, #ff5c5c);
background: color-mix(in srgb, var(--accent, #ff5c5c) 14%, transparent);
}
.bp-icon:disabled {
opacity: 0.4;
}
.bp-actions {
flex: none;
}
@@ -34,6 +27,23 @@ export const browserPanelStyles = css`
padding: 5px 8px;
border-bottom: 1px solid var(--border, #262b34);
}
.bp-toolbar .bp-icon {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--muted, #8a919e);
}
.bp-toolbar .bp-icon:hover,
.bp-toolbar .bp-icon:focus-visible {
background: color-mix(in srgb, var(--text, #d7dae0) 10%, transparent);
color: var(--text, #d7dae0);
}
.bp-url {
flex: 1;
min-width: 0;
@@ -105,17 +105,17 @@ export class OpenClawCustodianPanel extends OpenClawLightDomElement {
return html`
<section class="cp cp--${dock}" style=${style} aria-label=${t("custodian.panel.title")}>
${this.dockLayout.renderResizer("cp", t("custodian.panel.resize"))}
<header class="cp-header">
<header class="rail-header cp-header">
<div class="cp-title">
<openclaw-mascot
.mood=${this.store.sending ? "thinking" : "idle"}
.size=${26}
.size=${16}
></openclaw-mascot>
<strong>${t("custodian.panel.title")}</strong>
<strong class="rail-header__title">${t("custodian.panel.title")}</strong>
</div>
<div class="cp-actions">
<div class="rail-header__actions cp-actions">
<button
class="cp-icon"
class="rail-header__action cp-icon"
type="button"
aria-label=${dock === "bottom"
? t("custodian.panel.dockRight")
@@ -125,7 +125,7 @@ export class OpenClawCustodianPanel extends OpenClawLightDomElement {
${dock === "bottom" ? icons.panelRightOpen : icons.panelBottomOpen}
</button>
<button
class="cp-icon"
class="rail-header__action cp-icon"
type="button"
aria-label=${t("custodian.panel.close")}
@click=${() => this.dockLayout.setOpen(false)}
@@ -13,13 +13,6 @@ export const desktopPanelStyles = css`
}
.bp-title {
min-width: 0;
padding-left: 8px;
font-size: 13px;
font-weight: 600;
}
.bp-icon.is-active {
color: var(--accent, #ff5c5c);
background: color-mix(in srgb, var(--accent, #ff5c5c) 14%, transparent);
}
.desktop-content {
display: flex;
+10 -14
View File
@@ -1,50 +1,46 @@
import type { EnvironmentSummary, WorkerDesktopAppId } from "@openclaw/gateway-protocol";
import { html, nothing, svg } from "lit";
import { html, nothing } from "lit";
import { t } from "../../i18n/index.ts";
import { icons } from "../icons.ts";
import { desktopAppIcon, desktopAppLabel } from "./desktop-app-presentation.ts";
import type { DesktopPanelState } from "./desktop-panel-state.ts";
import { desktopSourceForEnvironment } from "./desktop-source.ts";
const CLOSE_GLYPH = svg`<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>`;
const DOCK_BOTTOM_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M2 10h12" /></svg>`;
const DOCK_RIGHT_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M10 2.5v11" /></svg>`;
export function renderDesktopPanelHeader(options: {
dock: "bottom" | "right";
onClose: () => void;
onDock: (dock: "bottom" | "right") => void;
}) {
return html`
<header class="bp-header">
<div class="bp-title">${t("desktop.title")}</div>
<div class="bp-actions">
<header class="rail-header bp-header">
<div class="rail-header__title bp-title">${t("desktop.title")}</div>
<div class="rail-header__actions bp-actions">
<button
class="bp-icon ${options.dock === "bottom" ? "is-active" : ""}"
class="rail-header__action bp-icon ${options.dock === "bottom" ? "is-active" : ""}"
type="button"
title=${t("desktop.dockBottom")}
aria-label=${t("desktop.dockBottom")}
@click=${() => options.onDock("bottom")}
>
${DOCK_BOTTOM_GLYPH}
${icons.panelBottomOpen}
</button>
<button
class="bp-icon ${options.dock === "right" ? "is-active" : ""}"
class="rail-header__action bp-icon ${options.dock === "right" ? "is-active" : ""}"
type="button"
title=${t("desktop.dockRight")}
aria-label=${t("desktop.dockRight")}
@click=${() => options.onDock("right")}
>
${DOCK_RIGHT_GLYPH}
${icons.panelRightOpen}
</button>
<button
class="bp-icon"
class="rail-header__action bp-icon"
type="button"
title=${t("desktop.hide")}
aria-label=${t("desktop.hide")}
@click=${options.onClose}
>
${CLOSE_GLYPH}
${icons.x}
</button>
</div>
</header>
+131 -34
View File
@@ -15,6 +15,8 @@ type DockLayoutControllerOptions<TDock extends DockPanelPlacement> = {
reservationPrefix: string;
isAvailable: () => boolean;
isFullscreen?: () => boolean;
maxWidth?: () => number;
reserveViewport?: boolean;
onResize?: () => void;
};
@@ -28,7 +30,7 @@ export class DockLayoutController<TDock extends DockPanelPlacement> implements R
private resizeCleanup: (() => void) | null = null;
private readonly onViewportResize = () => {
const height = Math.min(this.height, this.options.layout.maxHeight());
const width = Math.min(this.width, this.options.layout.maxWidth());
const width = Math.min(this.width, this.maxWidth());
if (height === this.height && width === this.width) {
return;
}
@@ -58,7 +60,7 @@ export class DockLayoutController<TDock extends DockPanelPlacement> implements R
this.open = layout.open && this.options.isAvailable();
this.dock = layout.dock;
this.height = layout.height;
this.width = layout.width;
this.width = Math.min(layout.width, this.maxWidth());
window.addEventListener("resize", this.onViewportResize);
}
@@ -138,7 +140,7 @@ export class DockLayoutController<TDock extends DockPanelPlacement> implements R
}
syncReservation(): void {
if (this.isFullscreen()) {
if (this.isFullscreen() || this.options.reserveViewport === false) {
return;
}
const visible = this.options.isAvailable() && this.open;
@@ -166,7 +168,7 @@ export class DockLayoutController<TDock extends DockPanelPlacement> implements R
this.height = Math.min(next, this.options.layout.maxHeight());
} else {
const next = Math.max(this.options.layout.minWidth, startWidth + (startX - move.clientX));
this.width = Math.min(next, this.options.layout.maxWidth());
this.width = Math.min(next, this.maxWidth());
}
this.syncReservation();
this.options.onResize?.();
@@ -212,6 +214,9 @@ export class DockLayoutController<TDock extends DockPanelPlacement> implements R
}
private clearReservation(): void {
if (this.options.reserveViewport === false) {
return;
}
const root = document.documentElement.style;
root.setProperty(`--oc-${this.options.reservationPrefix}-reserve-bottom`, "0px");
root.setProperty(`--oc-${this.options.reservationPrefix}-reserve-right`, "0px");
@@ -220,6 +225,16 @@ export class DockLayoutController<TDock extends DockPanelPlacement> implements R
private isFullscreen(): boolean {
return this.options.isFullscreen?.() === true;
}
private maxWidth(): number {
return Math.max(
this.options.layout.minWidth,
Math.min(
this.options.layout.maxWidth(),
this.options.maxWidth?.() ?? Number.POSITIVE_INFINITY,
),
);
}
}
export const dockPanelStyles = css`
@@ -236,64 +251,146 @@ export const dockPanelStyles = css`
background: var(--bg, #0e1015);
overflow: hidden;
}
:is(.bp--bottom, .tp--bottom) {
border-top: 1px solid var(--border, #262b34);
}
:is(.bp--right, .tp--right) {
border-left: 1px solid var(--border, #262b34);
}
:is(.bp-resizer, .tp-resizer) {
position: absolute;
z-index: 2;
background: transparent;
}
:is(.bp-resizer, .tp-resizer):hover {
background: var(--accent, #ff5c5c);
opacity: 0.5;
:is(.bp-resizer, .tp-resizer)::after {
position: absolute;
content: "";
background: var(--rail-divider-color, var(--border, #262b34));
transition:
background 150ms ease-out,
width 150ms ease-out,
height 150ms ease-out;
}
:is(.bp-resizer--bottom, .tp-resizer--bottom) {
top: 0;
left: 0;
right: 0;
height: 5px;
height: var(--rail-resizer-size, 4px);
cursor: ns-resize;
}
:is(.bp-resizer--bottom, .tp-resizer--bottom)::after {
top: 50%;
right: 0;
left: 0;
height: var(--rail-divider-size, 1px);
transform: translateY(-50%);
}
:is(.bp-resizer--right, .tp-resizer--right) {
top: 0;
bottom: 0;
left: 0;
width: 5px;
width: var(--rail-resizer-size, 4px);
cursor: ew-resize;
}
:is(.bp-header, .tp-header) {
:is(.bp-resizer--right, .tp-resizer--right)::after {
top: 0;
bottom: 0;
left: 50%;
width: var(--rail-divider-size, 1px);
transform: translateX(-50%);
}
:is(.bp-resizer--bottom, .tp-resizer--bottom):hover::after {
height: var(--rail-divider-active-size, 2px);
background: var(--accent, #ff5c5c);
}
:is(.bp-resizer--right, .tp-resizer--right):hover::after {
width: var(--rail-divider-active-size, 2px);
background: var(--accent, #ff5c5c);
}
.rail-header {
box-sizing: border-box;
display: flex;
height: var(--rail-header-height, 48px);
min-height: var(--rail-header-height, 48px);
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 6px 0 4px;
border-bottom: 1px solid var(--border, #262b34);
min-height: 36px;
padding: 0 var(--rail-header-padding-end, 8px) 0 var(--rail-header-padding-start, 12px);
border-bottom: var(--rail-divider-size, 1px) solid
var(--rail-divider-color, var(--border, #262b34));
background: var(--rail-header-background, var(--bg, #0e1015));
}
:is(.bp-icon, .tp-icon) {
.rail-header__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--rail-header-action-gap, 2px);
}
.rail-header__copy {
display: flex;
min-width: 0;
flex: 1 1 auto;
flex-direction: column;
justify-content: center;
gap: var(--rail-header-copy-gap, 2px);
}
.rail-header__eyebrow {
overflow: hidden;
color: var(--muted, #8a919e);
font-size: var(--rail-header-eyebrow-size, 10px);
letter-spacing: var(--rail-header-eyebrow-letter-spacing, 0.04em);
line-height: 1;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.rail-header__title {
overflow: hidden;
color: var(--text, #d7dae0);
font-size: var(--rail-header-title-size, 12px);
font-weight: var(--rail-header-title-weight, 600);
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-header__action {
display: inline-flex;
width: var(--rail-header-action-size, 28px);
min-width: var(--rail-header-action-size, 28px);
height: var(--rail-header-action-size, 28px);
min-height: var(--rail-header-action-size, 28px);
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
background: transparent;
color: var(--muted, #8a919e);
border-radius: 6px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
box-shadow: none;
color: var(--rail-header-action-color, var(--muted, #8a919e));
font: inherit;
opacity: 1;
}
:is(.bp-icon, .tp-icon):hover {
background: color-mix(in srgb, var(--text, #d7dae0) 12%, transparent);
color: var(--text, #d7dae0);
.rail-header__action:hover,
.rail-header__action:focus-visible {
border: 0;
background: transparent;
box-shadow: none;
color: var(--rail-header-action-hover-color, var(--text, #d7dae0));
}
:is(.bp-actions, .tp-actions) {
display: flex;
align-items: center;
gap: 2px;
padding-left: 6px;
.rail-header__action:focus-visible {
outline: 2px solid var(--ring, var(--accent, #ff5c5c));
outline-offset: -3px;
}
.rail-header__action.is-active,
.rail-header__action[aria-pressed="true"] {
background: transparent;
color: var(--rail-header-action-active-color, var(--accent, #ff5c5c));
}
.rail-header__action:disabled,
.rail-header__action[aria-disabled="true"] {
opacity: var(--rail-header-action-disabled-opacity, 0.4);
}
.rail-header__action svg {
width: var(--rail-header-action-glyph-size, 16px);
height: var(--rail-header-action-glyph-size, 16px);
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
}
`;
@@ -1,9 +1,21 @@
/* @vitest-environment jsdom */
import type { ReactiveController } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createStorageMock } from "../test-helpers/storage.ts";
import { DockLayoutController } from "./dock-layout-controller.ts";
import { createDockPanelLayout, type DockPanelSide } from "./dock-panel-layout.ts";
function createControllerHost() {
return {
addController: vi.fn((_controller: ReactiveController) => undefined),
removeController: vi.fn((_controller: ReactiveController) => undefined),
requestUpdate: vi.fn(),
updateComplete: Promise.resolve(true),
isConnected: true,
};
}
function createLayout(defaultDock: DockPanelSide) {
return createDockPanelLayout({
storageKey: `test.dock-panel.${defaultDock}`,
@@ -103,3 +115,51 @@ describe("createDockPanelLayout", () => {
expect(layout.load()).toEqual({ open: true, dock: "main", height: 320, width: 520 });
});
});
describe("DockLayoutController inline columns", () => {
it("resizes and restores a width without reserving the global viewport", () => {
const layout = createDockPanelLayout({
storageKey: "test.dock-panel.inline",
minHeight: 140,
minWidth: 260,
defaultDock: "right",
supportedDocks: ["right"],
defaultHeight: 320,
defaultWidth: 280,
});
const reservation = "--oc-test-inline-reserve-right";
document.documentElement.style.setProperty(reservation, "17px");
const host = createControllerHost();
const controller = new DockLayoutController(host, {
layout,
reservationPrefix: "test-inline",
isAvailable: () => true,
maxWidth: () => 420,
reserveViewport: false,
});
controller.hostConnected();
controller.startResize(new MouseEvent("pointerdown", { clientX: 600 }) as PointerEvent);
window.dispatchEvent(new MouseEvent("pointermove", { clientX: 500 }));
window.dispatchEvent(new MouseEvent("pointerup"));
expect(controller.width).toBe(380);
expect(JSON.parse(localStorage.getItem("test.dock-panel.inline") ?? "{}")).toMatchObject({
width: 380,
});
expect(document.documentElement.style.getPropertyValue(reservation)).toBe("17px");
const restored = new DockLayoutController(createControllerHost(), {
layout,
reservationPrefix: "test-inline",
isAvailable: () => true,
maxWidth: () => 420,
reserveViewport: false,
});
restored.hostConnected();
expect(restored.width).toBe(380);
restored.hostDisconnected();
controller.hostDisconnected();
document.documentElement.style.removeProperty(reservation);
});
});
@@ -380,7 +380,6 @@ describeBrowserLayout("app chrome interaction styles", () => {
<span class="sidebar-agent-card__name">Agent</span>
<span class="settings-sidebar__item-label">Settings</span>
<span class="sidebar-file-view__path">workspace/file.ts</span>
<span class="chat-workbench__dock-zone">Dock here</span>
<span class="chat-workspace-rail__file-badge">3 files</span>
<span class="session-menu__shortcut">⌘K</span>
<div class="file-view__search">
@@ -412,7 +411,6 @@ describeBrowserLayout("app chrome interaction styles", () => {
".sidebar-agent-card__name",
".settings-sidebar__item-label",
".sidebar-file-view__path",
".chat-workbench__dock-zone",
".chat-workspace-rail__file-badge",
".session-menu__shortcut",
".file-view__search-counter",
+10 -61
View File
@@ -1,6 +1,7 @@
import { css, html, nothing, svg, type TemplateResult } from "lit";
import { css, html, nothing, type TemplateResult } from "lit";
import { ref } from "lit/directives/ref.js";
import { repeat } from "lit/directives/repeat.js";
import { icons } from "./icons.ts";
import "./web-awesome-tabs.ts";
export type PanelTabStripTab = {
@@ -16,9 +17,6 @@ export type PanelTabStripTab = {
closeLabel: string;
};
const CLOSE_GLYPH = svg`<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>`;
const PLUS_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M8 3v10M3 8h10" /></svg>`;
const reconciledTabOrders = new WeakMap<Element, string>();
const keyboardCloseActivations = new WeakSet<Element>();
@@ -95,14 +93,14 @@ export function renderPanelTabStrip(params: {
const newButton = (slotted: boolean) => html`
<button
slot=${slotted ? "nav" : nothing}
class="tabstrip-new"
class="rail-header__action tabstrip-new"
type="button"
?disabled=${params.newDisabled}
title=${params.newLabel}
aria-label=${params.newLabel}
@click=${params.onNew}
>
${PLUS_GLYPH}
${icons.plus}
</button>
`;
if (params.tabs.length === 0) {
@@ -163,7 +161,7 @@ export function renderPanelTabStrip(params: {
</wa-tab>
<button
slot="nav"
class="tabstrip-tab__close"
class="rail-header__action tabstrip-tab__close"
type="button"
title=${tab.closeLabel}
aria-label=${tab.closeLabel}
@@ -212,7 +210,7 @@ export function renderPanelTabStrip(params: {
}
}}
>
<span class="tabstrip-tab__close-box">${CLOSE_GLYPH}</span>
<span class="tabstrip-tab__close-box">${icons.x}</span>
</button>
`;
},
@@ -296,27 +294,14 @@ export const panelTabStripStyles = css`
padding: 0 5px;
text-transform: uppercase;
}
/* Each close button sits right after its tab in the nav slot; the pair is
styled as one surface (shared hover background, shared active underline)
while the X keeps its own inner highlight. */
/* Each close button sits right after its tab in the nav slot. The tab keeps
the active surface; the action itself follows the bare rail-control contract. */
.tabstrip-tab__close {
display: inline-flex;
align-items: center;
justify-content: center;
align-self: stretch;
flex: 0 0 auto;
width: 24px;
align-self: center;
margin-right: 1px;
padding: 0 4px 0 0;
opacity: 0;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--muted, #8a919e);
transition:
color 0.12s ease,
background 0.12s ease,
opacity 0.12s ease;
transition: opacity 0.12s ease;
}
.tabstrip-tab__close-box {
display: inline-flex;
@@ -331,45 +316,9 @@ export const panelTabStripStyles = css`
.tabstrip-tab__close:focus-visible {
opacity: 1;
}
.tabstrip-tab:hover + .tabstrip-tab__close,
.tabstrip-tab__close:hover,
.tabstrip-tab__close:focus-visible {
background: color-mix(in srgb, var(--text, #d7dae0) 6%, transparent);
}
/* Back-propagate hover from the X to its tab so the pair lights up together. */
.tabstrip-tab:has(+ .tabstrip-tab__close:hover)::part(base),
.tabstrip-tab:has(+ .tabstrip-tab__close:focus-visible)::part(base) {
color: var(--text, #d7dae0);
background: color-mix(in srgb, var(--text, #d7dae0) 6%, transparent);
}
.tabstrip-tab[active] + .tabstrip-tab__close {
border-bottom-color: var(--accent, #ff5c5c);
}
.tabstrip-tab__close:hover,
.tabstrip-tab__close:focus-visible {
color: var(--text, #d7dae0);
}
.tabstrip-tab__close:hover .tabstrip-tab__close-box,
.tabstrip-tab__close:focus-visible .tabstrip-tab__close-box {
background: color-mix(in srgb, var(--text, #d7dae0) 14%, transparent);
}
.tabstrip-new {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
align-self: center;
width: 26px;
height: 26px;
border: none;
background: transparent;
color: var(--muted, #8a919e);
border-radius: 6px;
padding: 0;
}
.tabstrip-new:hover {
background: color-mix(in srgb, var(--text, #d7dae0) 12%, transparent);
color: var(--text, #d7dae0);
}
@keyframes tabstrip-pulse {
50% {
+7 -7
View File
@@ -24,7 +24,7 @@ class ResizableDivider extends OpenClawLitElement {
static override styles = css`
:host {
width: 4px;
width: var(--rail-resizer-size, 4px);
cursor: col-resize;
flex-shrink: 0;
position: relative;
@@ -48,9 +48,9 @@ class ResizableDivider extends OpenClawLitElement {
top: 0;
bottom: 0;
left: 50%;
width: 1px;
width: var(--rail-divider-size, 1px);
transform: translateX(-50%);
background: var(--border, #1e2028);
background: var(--rail-divider-color, var(--border, #1e2028));
transition:
background 150ms ease-out,
width 150ms ease-out;
@@ -58,7 +58,7 @@ class ResizableDivider extends OpenClawLitElement {
:host(:hover)::after,
:host(.dragging)::after,
:host(:focus-visible)::after {
width: 2px;
width: var(--rail-divider-active-size, 2px);
background: var(--accent, #ff5c5c);
}
:host(:focus-visible) {
@@ -67,7 +67,7 @@ class ResizableDivider extends OpenClawLitElement {
}
:host([orientation="horizontal"]) {
width: auto;
height: 4px;
height: var(--rail-resizer-size, 4px);
cursor: row-resize;
}
:host([orientation="horizontal"])::before {
@@ -82,7 +82,7 @@ class ResizableDivider extends OpenClawLitElement {
left: 0;
right: 0;
width: auto;
height: 1px;
height: var(--rail-divider-size, 1px);
transform: translateY(-50%);
transition:
background 150ms ease-out,
@@ -92,7 +92,7 @@ class ResizableDivider extends OpenClawLitElement {
:host([orientation="horizontal"].dragging)::after,
:host([orientation="horizontal"]:focus-visible)::after {
width: auto;
height: 2px;
height: var(--rail-divider-active-size, 2px);
}
`;
@@ -40,7 +40,7 @@ export function renderTerminalPanelHeader(
closeTab: (id: string) => void | Promise<void>,
openSession: () => void,
): TemplateResult {
return html`<header class="tp-header">
return html`<header class="rail-header tp-header">
${renderTerminalPanelTabs({
tabs,
activeId,
@@ -24,13 +24,6 @@ export const terminalPanelStyles = css`
.tp--fullscreen {
inset: 0;
}
.tp-header {
background: var(--bg, #0e1015);
}
.tp-icon.is-active {
color: var(--text, #d7dae0);
background: color-mix(in srgb, var(--text, #d7dae0) 10%, transparent);
}
.tp-dock-modes {
display: flex;
align-items: center;
@@ -1,8 +1,8 @@
import { css } from "lit";
export const terminalPanelUploadStyles = css`
.tp-icon:disabled {
opacity: 0.35;
.rail-header__action:disabled {
opacity: var(--rail-header-action-disabled-opacity, 0.4);
pointer-events: none;
}
.tp-file-input {
@@ -1,6 +1,7 @@
import type { GhosttyTerminalController } from "@openclaw/libterminal/browser";
import { html, nothing, svg } from "lit";
import { html, nothing } from "lit";
import { t } from "../../i18n/index.ts";
import { icons } from "../icons.ts";
import type { TerminalGatewayClient } from "./terminal-connection.ts";
import {
encodeTerminalUpload,
@@ -8,13 +9,6 @@ import {
uploadTerminalFile,
} from "./terminal-file-upload.ts";
const CLOSE_GLYPH = svg`<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>`;
const DOCK_BOTTOM_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M2 10h12" /></svg>`;
const DOCK_RIGHT_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M10 2.5v11" /></svg>`;
const DOCK_MAIN_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"><path d="M6 2.5H2.5V6M10 2.5h3.5V6M6 13.5H2.5V10M10 13.5h3.5V10" /></svg>`;
const OPEN_FULLSCREEN_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><path d="M9 2.5h4.5V7M13.5 2.5 8 8" /><path d="M7 3H3.5A1.5 1.5 0 0 0 2 4.5v8A1.5 1.5 0 0 0 3.5 14h8a1.5 1.5 0 0 0 1.5-1.5V9" /></svg>`;
const UPLOAD_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5.2 8.1 9.8 3.5a2.5 2.5 0 0 1 3.5 3.5l-6 6a3.5 3.5 0 0 1-5-5l5.8-5.8" /><path d="m4.4 9 5.2-5.2a1.4 1.4 0 0 1 2 2l-5.3 5.3a2.3 2.3 0 0 1-3.2-3.2l4.6-4.6" /></svg>`;
type TerminalUploadTab = {
gatewaySessionId: string;
shell: string;
@@ -324,7 +318,7 @@ export function renderTerminalPanelActions(params: {
onOpenFullscreen: () => void;
onHide: () => void;
}) {
return html`<div class="tp-actions">
return html`<div class="rail-header__actions tp-actions">
<input
class="tp-file-input"
type="file"
@@ -334,14 +328,14 @@ export function renderTerminalPanelActions(params: {
@change=${params.upload.handleFileSelection}
/>
<button
class="tp-icon tp-upload"
class="rail-header__action tp-icon tp-upload"
type="button"
title=${t("terminal.addFiles")}
aria-label=${t("terminal.addFiles")}
?disabled=${params.upload.hasPendingBatch() || !params.upload.hasActiveTab()}
@click=${params.upload.chooseFiles}
>
${UPLOAD_GLYPH}
${icons.paperclip}
</button>
${params.fullscreen
? nothing
@@ -350,49 +344,49 @@ export function renderTerminalPanelActions(params: {
role="group"
aria-label=${t("terminal.dockMode")}
><button
class="tp-icon ${params.dock === "bottom" ? "is-active" : ""}"
class="rail-header__action tp-icon ${params.dock === "bottom" ? "is-active" : ""}"
type="button"
title=${t("terminal.dockBottom")}
aria-label=${t("terminal.dockBottom")}
@click=${() => params.onDock("bottom")}
>
${DOCK_BOTTOM_GLYPH}
${icons.panelBottomOpen}
</button>
<button
class="tp-icon ${params.dock === "right" ? "is-active" : ""}"
class="rail-header__action tp-icon ${params.dock === "right" ? "is-active" : ""}"
type="button"
title=${t("terminal.dockRight")}
aria-label=${t("terminal.dockRight")}
@click=${() => params.onDock("right")}
>
${DOCK_RIGHT_GLYPH}</button
${icons.panelRightOpen}</button
><button
class="tp-icon ${params.dock === "main" ? "is-active" : ""}"
class="rail-header__action tp-icon ${params.dock === "main" ? "is-active" : ""}"
type="button"
title=${t("terminal.dockMain")}
aria-label=${t("terminal.dockMain")}
@click=${() => params.onDock("main")}
>
${DOCK_MAIN_GLYPH}
${icons.columns2}
</button>
</span>
<button
class="tp-icon tp-open-fullscreen"
class="rail-header__action tp-icon tp-open-fullscreen"
type="button"
title=${t("terminal.openFullscreen")}
aria-label=${t("terminal.openFullscreen")}
@click=${params.onOpenFullscreen}
>
${OPEN_FULLSCREEN_GLYPH}
${icons.maximize}
</button>
<button
class="tp-icon"
class="rail-header__action tp-icon"
type="button"
title=${t("terminal.hide")}
aria-label=${t("terminal.hide")}
@click=${params.onHide}
>
${CLOSE_GLYPH}
${icons.x}
</button>`}
</div>`;
}
@@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { t } from "../../i18n/index.ts";
import { icons } from "../icons.ts";
import type { TerminalSessionInfo } from "./terminal-connection.ts";
type TerminalSessionPickerProps = {
@@ -20,7 +21,7 @@ export function renderTerminalSessionPicker(props: TerminalSessionPickerProps) {
return html`
<div class="tp-session-picker" @focusout=${props.onFocusOut}>
<button
class="tp-icon"
class="rail-header__action tp-icon"
type="button"
title=${t("terminal.sessions")}
aria-label=${t("terminal.sessions")}
@@ -29,18 +30,7 @@ export function renderTerminalSessionPicker(props: TerminalSessionPickerProps) {
aria-controls=${TERMINAL_SESSION_PICKER_ID}
@click=${props.onToggle}
>
<svg
viewBox="0 0 16 16"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="1.3"
aria-hidden="true"
>
<path d="M3 3.25h10v3.5H3zM3 9.25h10v3.5H3z" />
<path d="m5 4.5 1 1-1 1m0 4 1 1-1 1" />
</svg>
${icons.server}
</button>
${props.open
? html`<div
@@ -113,7 +113,7 @@ suite.define(() => {
const showFiles = page.getByRole("button", { name: "Show session files", exact: true });
await showFiles.waitFor();
await showFiles.click();
const toggle = page.getByRole("button", { name: "Toggle browser panel", exact: true });
const toggle = page.locator(".chat-browser-panel-toggle");
await toggle.waitFor();
await toggle.click();
@@ -300,7 +300,8 @@ suite.define(() => {
targetHeader.evaluate((header) => {
const owner = header.closest("openclaw-chat-pane");
return (
owner === header.parentElement && owner?.classList.contains("chat-split-view__pane")
header.parentElement?.classList.contains("chat-main__conversation-column") === true &&
owner?.classList.contains("chat-split-view__pane") === true
);
}),
)
+849
View File
@@ -0,0 +1,849 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Locator, Page } from "playwright";
import { expect, it } from "vitest";
import {
controlUiBundledSettingsStorageKey,
installMockGateway,
type ControlUiMockGatewayScenario,
} from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const suite = createControlUiE2eSuite({
name: "chat rail columns",
startServerBeforeBrowser: true,
});
const sessionKey = "agent:main:rail-columns";
const proofDir = process.env.OPENCLAW_UI_RAIL_PROOF_DIR?.trim();
const theme = process.env.OPENCLAW_UI_RAIL_PROOF_THEME === "dark" ? "dark" : "light";
const proofPhase = process.env.OPENCLAW_UI_RAIL_PROOF_PHASE === "before" ? "before" : "after";
const historyMessages = Array.from({ length: 8 }, (_, index) => ({
id: `rail-proof-${index}`,
role: index % 2 === 0 ? "user" : "assistant",
content: [
{
type: "text",
text:
index % 2 === 0
? `Review rail layout checkpoint ${index + 1}. Keep the transcript readable while supporting the work beside it.`
: `Checkpoint ${index + 1} is ready. The column should start above this message and keep its own chrome, width, and scroll surface.`,
},
],
timestamp: Date.now() - (8 - index) * 60_000,
}));
function railScenario(): ControlUiMockGatewayScenario {
return {
featureMethods: [
"browser.request",
"chat.metadata",
"chat.startup",
"board.get",
"desktop.observe",
"environments.list",
"openclaw.chat",
"session.discussion.info",
"session.discussion.open",
"sessions.diff",
"tasks.list",
"terminal.open",
],
historyMessages,
methodResponses: {
"artifacts.list": { artifacts: [] },
"browser.request": {
cases: [
{
match: { method: "GET", path: "/tabs" },
response: { running: true, tabs: [] },
},
],
},
"board.get": {
sessionKey,
revision: 1,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }],
widgets: [
{
name: "release-status",
tabId: "main",
title: "Release status",
contentKind: "html",
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "pending",
revision: 1,
frameUrl: "about:blank#release-status",
},
],
},
"environments.list": {
environments: [{ id: "gateway", type: "local", status: "available", desktop: true }],
},
"openclaw.chat": {
sessionId: "rail-proof-custodian",
reply: "The shared rail header contract is ready for review.",
action: "none",
},
"session.discussion.info": {
embedUrl: "https://discussion.example/embed/channel/T1/C1?openclawHostTheme=1",
openUrl: "https://discussion.example/session",
state: "open",
},
"session.discussion.open": {
embedUrl: "https://discussion.example/embed/channel/T1/C1?openclawHostTheme=1",
openUrl: "https://discussion.example/session",
state: "open",
},
"sessions.diff": {
sessionKey,
root: "/workspace/openclaw",
branch: "feature/full-height-rails",
baseRef: "main",
files: [
{
path: "ui/src/pages/chat/chat-view.ts",
status: "modified",
additions: 3,
deletions: 1,
patch: [
"diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts",
"--- a/ui/src/pages/chat/chat-view.ts",
"+++ b/ui/src/pages/chat/chat-view.ts",
"@@ -1,2 +1,4 @@",
" existing line",
"+full-height rail",
"+persisted width",
"+shared resize handle",
"",
].join("\n"),
},
],
additions: 3,
deletions: 1,
},
"sessions.files.list": {
browser: {
path: "ui/src/pages/chat",
entries: [
{ kind: "file", name: "chat-view.ts", path: "ui/src/pages/chat/chat-view.ts" },
{
kind: "file",
name: "chat-pane-render.ts",
path: "ui/src/pages/chat/chat-pane-render.ts",
},
],
},
files: [
{
kind: "modified",
missing: false,
name: "chat-view.ts",
path: "/workspace/openclaw/ui/src/pages/chat/chat-view.ts",
size: 15_432,
},
{
kind: "read",
missing: false,
name: "sidebar.css",
path: "/workspace/openclaw/ui/src/styles/chat/sidebar.css",
size: 22_840,
},
],
root: "/workspace/openclaw",
sessionKey,
},
"tasks.list": {
tasks: [
{
agentId: "main",
createdAt: Date.now() - 240_000,
id: "task-layout",
kind: "subagent",
ownerKey: sessionKey,
sessionKey,
progressSummary: "Comparing column geometry in both themes",
runtime: "subagent",
startedAt: Date.now() - 210_000,
status: "running",
taskId: "task-layout",
title: "Verify rail layout",
updatedAt: Date.now(),
},
{
agentId: "main",
createdAt: Date.now() - 420_000,
id: "task-history",
kind: "subagent",
ownerKey: sessionKey,
sessionKey,
progressSummary: "Mapped the terminal and rail layout history",
runtime: "subagent",
startedAt: Date.now() - 410_000,
status: "completed",
taskId: "task-history",
title: "Inspect layout history",
updatedAt: Date.now() - 120_000,
},
],
},
"terminal.list": { sessions: [] },
"terminal.open": {
agentId: "main",
confined: false,
cwd: "/workspace/openclaw",
sessionId: "rail-proof-terminal",
shell: "/bin/zsh",
},
},
sessionKey,
terminalEnabled: true,
workspace: "/workspace/openclaw",
workspaceGit: true,
};
}
async function seedTheme(
page: Page,
options: { workspaceDock?: "bottom"; workspaceWidth?: number } = {},
): Promise<void> {
const settingsKey = controlUiBundledSettingsStorageKey(suite.server.baseUrl);
await page.addInitScript(
({ key, mode, workspaceDock }) => {
localStorage.setItem(
key,
JSON.stringify({
theme: "claw",
themeMode: mode,
...(workspaceDock ? { chatWorkspaceDock: workspaceDock } : {}),
boardSessionViews: {
"agent:main:rail-columns": { activeTabId: "main", face: "dashboard" },
},
}),
);
},
{ key: settingsKey, mode: theme, workspaceDock: options.workspaceDock },
);
if (options.workspaceWidth) {
await page.addInitScript((width) => {
localStorage.setItem(
"openclaw.control.chat-workspace-rail.v1",
JSON.stringify({ dock: "right", height: 320, open: false, width }),
);
}, options.workspaceWidth);
}
}
async function expectFullHeightRail(page: Page, rail: Locator): Promise<void> {
const geometry = await page.locator(".chat-split-view__cell").evaluate(
(cell, railElement) => {
if (!(railElement instanceof HTMLElement)) {
throw new Error("Rail element is missing");
}
const cellRect = cell.getBoundingClientRect();
const railRect = railElement.getBoundingClientRect();
const headerRect = cell
.querySelector<HTMLElement>(".chat-pane__header")
?.getBoundingClientRect();
return {
cellTop: cellRect.top,
headerBottom: headerRect?.bottom ?? 0,
railTop: railRect.top,
};
},
await rail.elementHandle(),
);
expect(
Math.abs(geometry.railTop - geometry.cellTop),
JSON.stringify(geometry),
).toBeLessThanOrEqual(1);
expect(geometry.railTop, JSON.stringify(geometry)).toBeLessThan(geometry.headerBottom - 10);
}
async function expectSharedRailHeaders(page: Page, headers: Locator[]): Promise<void> {
await Promise.all(headers.map((header) => header.waitFor({ state: "visible" })));
const metrics = await Promise.all(
headers.map((header) =>
header.evaluate((element) => {
const style = getComputedStyle(element);
return {
alignItems: style.alignItems,
borderBottomColor: style.borderBottomColor,
borderBottomWidth: style.borderBottomWidth,
height: element.getBoundingClientRect().height,
paddingLeft: style.paddingLeft,
paddingRight: style.paddingRight,
};
}),
),
);
expect(
metrics.every((metric) => Math.abs(metric.height - 48) <= 1),
JSON.stringify(metrics),
).toBe(true);
expect(new Set(metrics.map((metric) => metric.borderBottomColor)).size).toBe(1);
for (const metric of metrics) {
expect(metric).toMatchObject({
alignItems: "center",
borderBottomWidth: "1px",
paddingLeft: "12px",
paddingRight: "8px",
});
}
}
async function expectSingleRailSeparator(page: Page, selector: string): Promise<void> {
const separators = page.locator(selector);
await separators.first().waitFor();
const metrics = await separators.evaluateAll((elements) =>
elements.map((element) => {
const line = getComputedStyle(element, "::after");
const probe = document.createElement("div");
probe.style.background = "var(--rail-divider-color)";
document.body.append(probe);
const railDivider = getComputedStyle(probe).backgroundColor;
probe.remove();
return { background: line.backgroundColor, railDivider, width: line.width };
}),
);
expect(metrics.length).toBeGreaterThan(0);
for (const metric of metrics) {
expect(metric.width).toBe("1px");
expect(metric.background).toBe(metric.railDivider);
}
}
async function expectSharedRailActions(page: Page, actions: Locator[]): Promise<void> {
const metrics = [];
for (const [index, action] of actions.entries()) {
await action.waitFor();
const rest = await action.evaluate((element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
const glyph = element.querySelector("svg")?.getBoundingClientRect();
return {
background: style.backgroundColor,
borderBottomWidth: style.borderBottomWidth,
borderLeftWidth: style.borderLeftWidth,
borderRightWidth: style.borderRightWidth,
borderTopWidth: style.borderTopWidth,
color: style.color,
height: rect.height,
opacity: style.opacity,
glyphHeight: glyph?.height ?? 0,
glyphWidth: glyph?.width ?? 0,
width: rect.width,
};
});
await action.hover();
const hover = await action.evaluate((element) => {
const style = getComputedStyle(element);
return { background: style.backgroundColor, color: style.color };
});
const sentinelId = `rail-focus-sentinel-${index}`;
await action.evaluate((element, id) => {
const sentinel = document.createElement("button");
sentinel.id = id;
sentinel.type = "button";
sentinel.style.cssText = "position:fixed;width:1px;height:1px;opacity:0;pointer-events:none";
element.before(sentinel);
sentinel.focus();
}, sentinelId);
await page.keyboard.press("Tab");
const focus = await action.evaluate((element) => {
const style = getComputedStyle(element);
const root = element.getRootNode();
const activeElement =
root instanceof ShadowRoot ? root.activeElement : document.activeElement;
return {
active: activeElement === element,
background: style.backgroundColor,
color: style.color,
focusVisible: element.matches(":focus-visible"),
outlineColor: style.outlineColor,
outlineStyle: style.outlineStyle,
outlineWidth: style.outlineWidth,
};
});
await page.locator(`#${sentinelId}`).evaluate((element) => element.remove());
await action.evaluate((element) => (element as HTMLElement).blur());
metrics.push({ focus, hover, rest });
}
expect(
metrics.every(
({ rest }) =>
Math.abs(rest.width - 28) <= 1 &&
Math.abs(rest.height - 28) <= 1 &&
Math.abs(rest.glyphWidth - 16) <= 1 &&
Math.abs(rest.glyphHeight - 16) <= 1,
),
JSON.stringify(metrics),
).toBe(true);
for (const metric of metrics) {
expect(metric.rest).toMatchObject({
background: "rgba(0, 0, 0, 0)",
borderBottomWidth: "0px",
borderLeftWidth: "0px",
borderRightWidth: "0px",
borderTopWidth: "0px",
opacity: "1",
});
expect(metric.hover.background).toBe("rgba(0, 0, 0, 0)");
expect(metric.focus).toMatchObject({
active: true,
background: "rgba(0, 0, 0, 0)",
focusVisible: true,
outlineStyle: "solid",
});
}
expect(new Set(metrics.map(({ rest }) => rest.color)).size).toBe(1);
expect(new Set(metrics.map(({ hover }) => hover.color)).size).toBe(1);
expect(new Set(metrics.map(({ focus }) => focus.color)).size).toBe(1);
expect(new Set(metrics.map(({ focus }) => focus.outlineColor)).size).toBe(1);
expect(new Set(metrics.map(({ focus }) => focus.outlineWidth)).size).toBe(1);
expect(Number.parseFloat(metrics[0]?.focus.outlineWidth ?? "0")).toBeGreaterThanOrEqual(2);
}
async function expectSharedActiveRailActions(
activeActions: Locator[],
inactiveActions: Locator[],
): Promise<void> {
const read = (action: Locator) =>
action.evaluate((element) => {
const style = getComputedStyle(element);
return {
background: style.backgroundColor,
color: style.color,
opacity: style.opacity,
};
});
const active = await Promise.all(activeActions.map(read));
const inactive = await Promise.all(inactiveActions.map(read));
for (const metric of [...active, ...inactive]) {
expect(metric.background).toBe("rgba(0, 0, 0, 0)");
expect(metric.opacity).toBe("1");
}
expect(new Set(active.map((metric) => metric.color)).size).toBe(1);
expect(new Set(inactive.map((metric) => metric.color)).size).toBe(1);
expect(active[0]?.color).not.toBe(inactive[0]?.color);
}
async function navigateWithinApp(page: Page, routeId: string): Promise<void> {
await page.evaluate((targetRouteId) => {
const app = document.querySelector("openclaw-app") as HTMLElement & {
runtime?: { context: { navigate: (route: string) => void } };
};
if (!app.runtime) {
throw new Error("OpenClaw application runtime is unavailable");
}
app.runtime.context.navigate(targetRouteId);
}, routeId);
}
async function capture(page: Page, name: string): Promise<void> {
if (!proofDir) {
return;
}
await mkdir(proofDir, { recursive: true });
const baseName = `${proofPhase}-${name}-${theme}`;
await page.screenshot({ path: path.join(proofDir, `${baseName}-context.png`), fullPage: true });
await page
.locator(".chat-split-view__cell")
.screenshot({ path: path.join(proofDir, `${baseName}-crop.png`) });
}
suite.define(() => {
it("keeps every chat rail full-height and restores resized inline widths", async () => {
await suite.withPage(
{
colorScheme: theme,
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1600 },
},
async ({ page }) => {
await seedTheme(page);
await page.route("https://discussion.example/embed/channel/**", (route) =>
route.fulfill({
contentType: "text/html; charset=utf-8",
body: `<!doctype html><html><body style="margin:0;padding:20px;font:14px system-ui">
<h2>Session discussion</h2><p>Reviewing the rail layout with the team.</p>
</body></html>`,
}),
);
const gateway = await installMockGateway(page, railScenario());
await page.goto(`${suite.server.baseUrl}chat`);
await page.locator(".chat-group").first().waitFor();
await page.getByRole("button", { name: "Show background tasks" }).click();
await page.getByRole("button", { name: "Show session files", exact: true }).click();
const tasks = page.locator(".chat-tasks-rail");
const workspace = page.locator(".chat-workspace-rail");
await tasks.waitFor();
await workspace.waitFor();
await expect.poll(() => tasks.textContent()).toContain("Verify rail layout");
await page.getByRole("button", { name: "Show session companion" }).click();
const companion = page.locator("openclaw-chat-session-rail");
await companion.locator(".chat-session-rail--expanded").waitFor();
await companion.getByRole("button", { name: "Close session companion" }).waitFor();
await capture(page, "00-rail-header-contract");
if (proofPhase === "after") {
await expectFullHeightRail(page, tasks);
await expectFullHeightRail(page, workspace);
await expectFullHeightRail(page, companion);
await expectSharedRailHeaders(page, [
workspace.locator(".chat-workspace-rail__header"),
tasks.locator(".chat-tasks-rail__header"),
companion.locator(".chat-session-rail__header"),
]);
await expectSingleRailSeparator(page, ".chat-workspace-rail-resizer");
await expectSingleRailSeparator(page, ".chat-tasks-rail-resizer");
await expect
.poll(() =>
page
.locator(".chat-main")
.evaluate((element) => element.classList.contains("chat-main--rail-docked")),
)
.toBe(false);
await expect
.poll(() =>
companion
.locator(".chat-session-rail--expanded")
.evaluate((element) => getComputedStyle(element).borderLeftWidth),
)
.toBe("1px");
await expectSharedRailActions(page, [
workspace.getByRole("button", { name: "Collapse session workspace" }),
tasks.getByRole("button", { name: "Collapse background tasks" }),
companion.getByRole("button", { name: "Close session companion" }),
]);
}
await capture(page, "01-three-rails-default");
if (proofPhase === "after") {
const workspaceWidth = await workspace.evaluate(
(element) => element.getBoundingClientRect().width,
);
const workspaceHandle = page.locator(".chat-workspace-rail-resizer");
const handleBox = await workspaceHandle.boundingBox();
expect(handleBox).not.toBeNull();
await page.mouse.move(handleBox!.x + 2, handleBox!.y + handleBox!.height / 2);
await page.mouse.down();
await page.mouse.move(handleBox!.x - 84, handleBox!.y + handleBox!.height / 2);
await page.mouse.up();
await expect
.poll(() => workspace.evaluate((element) => element.getBoundingClientRect().width))
.toBeGreaterThan(workspaceWidth + 70);
const resizedWidth = await workspace.evaluate(
(element) => element.getBoundingClientRect().width,
);
await capture(page, "02-workspace-tasks-resized");
await workspace.getByRole("button", { name: "Collapse session workspace" }).click();
await page.getByRole("button", { name: "Show session files", exact: true }).click();
await expect
.poll(() => workspace.evaluate((element) => element.getBoundingClientRect().width))
.toBeCloseTo(resizedWidth, 0);
await capture(page, "03-workspace-tasks-reopened");
}
await workspace.getByRole("button", { name: "Show session changes" }).click();
await workspace.getByRole("button", { name: "Collapse session workspace" }).click();
await tasks.getByRole("button", { name: "Collapse background tasks" }).click();
if (proofPhase === "after") {
await expect
.poll(() =>
page
.locator(".chat-main")
.evaluate((element) => element.classList.contains("chat-main--rail-docked")),
)
.toBe(true);
await expectSingleRailSeparator(page, ".chat-companion-rail-resizer");
await expect
.poll(() =>
companion
.locator(".chat-session-rail--expanded")
.evaluate((element) => getComputedStyle(element).borderLeftWidth),
)
.toBe("0px");
}
const changes = page.locator('.sidebar-column[data-column-id="detail-column"]');
await changes.waitFor();
if (proofPhase === "after") {
await expectFullHeightRail(page, changes);
}
await capture(page, "04-changes");
await page.getByRole("button", { name: "Show discussion" }).click();
const discussion = page.locator('.sidebar-column[data-column-id="discussion-column"]');
await discussion.waitFor();
await expect.poll(() => page.locator("iframe.session-discussion__frame").count()).toBe(1);
if (proofPhase === "after") {
await expectFullHeightRail(page, discussion);
}
await capture(page, "05-discussion-clickclack");
if (proofPhase === "after") {
await expectFullHeightRail(page, companion);
}
await capture(page, "06-companion");
await page.getByRole("button", { name: "Hide discussion" }).press("Enter");
await expect.poll(() => discussion.count()).toBe(0);
await page.evaluate(() =>
window.dispatchEvent(
new CustomEvent("openclaw:browser-toggle", { detail: { open: true } }),
),
);
const browser = page.locator("openclaw-browser-panel");
await browser.locator(".bp-header").waitFor();
await browser.getByRole("button", { name: "Close browser panel" }).waitFor();
if (proofPhase === "after") {
await expectSharedRailHeaders(page, [
changes.locator(".sidebar-column__header"),
companion.locator(".chat-session-rail__header"),
browser.locator(".bp-header"),
]);
await expectSingleRailSeparator(page, '.sidebar-column__divider[role="separator"]');
await expectSingleRailSeparator(page, "openclaw-browser-panel .bp-resizer--right");
await expectSharedRailActions(page, [
changes.locator(".sidebar-column__actions button").last(),
companion.getByRole("button", { name: "Close session companion" }),
browser.getByRole("button", { name: "Close browser panel" }),
]);
await expectSharedActiveRailActions(
[browser.getByRole("button", { name: "Dock to right" })],
[browser.getByRole("button", { name: "Dock to bottom" })],
);
}
await capture(page, "07-companion-details-browser");
await page.evaluate(() =>
window.dispatchEvent(
new CustomEvent("openclaw:browser-toggle", { detail: { open: false } }),
),
);
await page.evaluate(() =>
window.dispatchEvent(
new CustomEvent("openclaw:terminal-toggle", { detail: { open: true } }),
),
);
await gateway.waitForRequest("terminal.open");
const terminal = page.locator("openclaw-terminal-panel");
await terminal.locator(".tp").waitFor();
if (proofPhase === "after") {
await expectSharedRailHeaders(page, [terminal.locator(".tp-header")]);
await expectSharedRailActions(page, [
terminal.getByRole("button", { name: "Hide terminal" }),
]);
await expectSharedActiveRailActions(
[terminal.getByRole("button", { name: "Dock to bottom" })],
[terminal.getByRole("button", { name: "Dock to right" })],
);
}
await capture(page, "08-terminal-reference");
await page.evaluate(() =>
window.dispatchEvent(
new CustomEvent("openclaw:terminal-toggle", { detail: { open: false } }),
),
);
await page.evaluate(() =>
window.dispatchEvent(
new CustomEvent("openclaw:desktop-toggle", { detail: { open: true } }),
),
);
const desktop = page.locator("openclaw-desktop-panel");
await desktop.locator(".bp-header").waitFor();
await gateway.waitForRequest("environments.list");
if (proofPhase === "after") {
await expectSharedRailHeaders(page, [
changes.locator(".sidebar-column__header"),
companion.locator(".chat-session-rail__header"),
desktop.locator(".bp-header"),
]);
await expectSingleRailSeparator(page, "openclaw-desktop-panel .bp-resizer--right");
// Desktop is a fixed overlay, so covered rail controls cannot receive a
// real pointer hover. Their interactive states were exercised above.
await expectSharedRailActions(page, [
desktop.getByRole("button", { name: "Hide desktop panel" }),
]);
await expectSharedActiveRailActions(
[desktop.getByRole("button", { name: "Dock to right" })],
[desktop.getByRole("button", { name: "Dock to bottom" })],
);
}
await capture(page, "09-companion-details-desktop");
await page.evaluate(() =>
window.dispatchEvent(
new CustomEvent("openclaw:desktop-toggle", { detail: { open: false } }),
),
);
await navigateWithinApp(page, "custodian");
const custodianComposer = page.getByLabel("Message OpenClaw…");
await custodianComposer.waitFor();
await custodianComposer.fill("Audit the shared rail header chrome.");
await page.getByRole("button", { name: "Send", exact: true }).click();
await expect
.poll(async () =>
(await gateway.getRequests("openclaw.chat")).some(
(request) =>
(request.params as { message?: unknown } | undefined)?.message ===
"Audit the shared rail header chrome.",
),
)
.toBe(true);
await expect
.poll(() =>
page
.getByText("The shared rail header contract is ready for review.", { exact: true })
.count(),
)
.toBeGreaterThanOrEqual(2);
await navigateWithinApp(page, "chat");
await page.locator(".chat-group").first().waitFor();
const custodian = page.locator("openclaw-custodian-panel");
await custodian.locator(".cp-header").waitFor();
const reopenedWorkspace = page.locator(".chat-workspace-rail");
if (!(await reopenedWorkspace.isVisible())) {
await page
.getByRole("button", { name: "Show session files", exact: true })
.press("Enter");
await reopenedWorkspace.waitFor();
}
if (!(await changes.isVisible())) {
await reopenedWorkspace
.getByRole("button", { name: "Show session changes" })
.press("Enter");
}
if (!(await companion.locator(".chat-session-rail__header").isVisible())) {
await page.getByRole("button", { name: "Show session companion" }).press("Enter");
}
await reopenedWorkspace
.getByRole("button", { name: "Collapse session workspace" })
.press("Enter");
await custodian.locator(".cp-header").waitFor();
if (proofPhase === "after") {
await changes.waitFor();
await companion.locator(".chat-session-rail__header").waitFor();
await expectSharedRailHeaders(page, [
changes.locator(".sidebar-column__header"),
companion.locator(".chat-session-rail__header"),
custodian.locator(".cp-header"),
]);
await expectSingleRailSeparator(page, "openclaw-custodian-panel .cp-resizer--right");
// Custodian is also a fixed overlay; covered controls were exercised
// before navigation, while its own action remains interactive here.
await expectSharedRailActions(page, [
custodian.getByRole("button", { name: "Close Ask OpenClaw" }),
]);
}
await capture(page, "10-custodian");
await custodian.getByRole("button", { name: "Close Ask OpenClaw" }).click();
await page.goto(`${suite.server.baseUrl}dashboard`);
const boardChat = page.locator('.sidebar-column[data-column-id="chat-column"]');
await boardChat.waitFor();
if (proofPhase === "after") {
await expectFullHeightRail(page, boardChat);
}
const dashboardHeader = page.locator(".chat-pane-primary-column > .chat-pane__header");
await expect
.poll(() =>
dashboardHeader.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).paddingLeft),
),
)
.toBeGreaterThanOrEqual(88);
await capture(page, "11-board-chat-web");
await page.evaluate(() => {
document.documentElement.classList.add("openclaw-native-macos", "openclaw-native-nav");
document.querySelector(".shell")?.classList.add("shell--nav-collapsed");
});
await expect
.poll(() => dashboardHeader.evaluate((element) => getComputedStyle(element).paddingLeft))
.toBe("204px");
await capture(page, "12-board-chat-native");
},
);
});
it("preserves a bottom workspace beside Tasks without applying the side-sibling cap", async () => {
await suite.withPage(
{
colorScheme: theme,
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1600 },
},
async ({ page }) => {
await seedTheme(page, { workspaceDock: "bottom", workspaceWidth: 820 });
await installMockGateway(page, railScenario());
await page.goto(`${suite.server.baseUrl}chat`);
await page.locator(".chat-group").first().waitFor();
await page.getByRole("button", { name: "Show session files", exact: true }).click();
const workbench = page.locator(".chat-workbench");
await expect
.poll(() => workbench.getAttribute("class"))
.toContain("chat-workbench--dock-bottom");
await page.evaluate(() => window.dispatchEvent(new Event("resize")));
await expect
.poll(() =>
workbench.evaluate((element) =>
Number.parseFloat(
getComputedStyle(element).getPropertyValue("--chat-workspace-rail-width"),
),
),
)
.toBeGreaterThan(0);
const widthBeforeTasks = await workbench.evaluate((element) =>
Number.parseFloat(
getComputedStyle(element).getPropertyValue("--chat-workspace-rail-width"),
),
);
await page.getByRole("button", { name: "Show background tasks" }).click();
await expect
.poll(() => workbench.getAttribute("class"))
.toContain("chat-workbench--tasks-open");
await expect
.poll(() => workbench.getAttribute("class"))
.not.toContain("chat-workbench--workspace-open");
await page.evaluate(() => window.dispatchEvent(new Event("resize")));
await expect
.poll(() =>
workbench.evaluate((element) =>
Number.parseFloat(
getComputedStyle(element).getPropertyValue("--chat-workspace-rail-width"),
),
),
)
.toBeGreaterThan(0);
const widthWithTasks = await workbench.evaluate((element) =>
Number.parseFloat(
getComputedStyle(element).getPropertyValue("--chat-workspace-rail-width"),
),
);
expect(widthWithTasks).toBeCloseTo(widthBeforeTasks, 0);
await capture(page, "10-bottom-workspace-tasks");
await page
.locator(".chat-workspace-rail")
.getByRole("button", { name: "Collapse session workspace" })
.click();
await page.getByRole("button", { name: "Show session files", exact: true }).click();
await expect
.poll(() => workbench.getAttribute("class"))
.toContain("chat-workbench--dock-bottom");
await capture(page, "12-bottom-workspace-reopened");
},
);
});
});
+2 -2
View File
@@ -97,8 +97,8 @@ suite.define(() => {
width: closeBounds.width,
};
});
expect(closeControlMetrics.width).toBe(24);
expect(closeControlMetrics.height).toBe(36);
expect(closeControlMetrics.width).toBe(28);
expect(closeControlMetrics.height).toBe(28);
expect(closeControlMetrics.centerOffset).toBeLessThanOrEqual(0.5);
const closeControl = page.locator("openclaw-terminal-panel").locator(".tabstrip-tab__close");
expect(await closeControl.getAttribute("aria-label")).toBe("Close terminal session: bash");
+5
View File
@@ -1956,6 +1956,7 @@ export const en: TranslationMap = {
title: "Browser",
toggle: "Toggle browser panel",
hide: "Hide browser panel",
close: "Close browser panel",
resize: "Resize browser panel",
newTab: "New tab",
closeTab: "Close tab",
@@ -2417,6 +2418,7 @@ export const en: TranslationMap = {
unsupportedGateway: "Update the Gateway to continue setup with OpenClaw.",
panel: {
title: "OpenClaw",
toggle: "Toggle Ask OpenClaw",
close: "Close Ask OpenClaw",
resize: "Resize Ask OpenClaw",
dockBottom: "Dock Ask OpenClaw at bottom",
@@ -5141,6 +5143,7 @@ export const en: TranslationMap = {
expand: "Expand session companion",
collapse: "Collapse session companion",
hide: "Hide session companion",
close: "Close session companion",
show: "Show session companion",
moreActions: "More companion actions",
clear: "Clear companion session",
@@ -5483,6 +5486,7 @@ export const en: TranslationMap = {
title: "Background tasks",
show: "Show background tasks",
collapse: "Collapse background tasks",
close: "Close background tasks",
refresh: "Refresh background tasks",
loading: "Loading background tasks…",
empty: "No background tasks for this agent yet.",
@@ -5579,6 +5583,7 @@ export const en: TranslationMap = {
workspaceFiles: {
label: "Session workspace",
collapse: "Collapse session workspace",
close: "Close session workspace",
showFiles: "Show session files",
dockRight: "Dock to the right",
dockBottom: "Dock to the bottom",
+111
View File
@@ -23,6 +23,8 @@ import {
type QuestionPrompt,
} from "../../app/question-prompt.ts";
import type { PresencePayload } from "../../app/user-profile.ts";
import { DockLayoutController } from "../../components/dock-layout-controller.ts";
import { t } from "../../i18n/index.ts";
import type {
BoardCommandEvent,
BoardProvider,
@@ -43,8 +45,12 @@ import { sendSessionObserverVisibility } from "./chat-observer.ts";
import {
boardChatDockLayout,
type ChatPaneConnectionScope,
chatCompanionRailLayout,
chatTasksRailLayout,
chatWorkspaceRailLayout,
type ChatPageContext,
type PaneSessionChangeOptions,
sidebarChatLayoutWidth,
} from "./chat-pane-shared.ts";
import { SessionParticipationTracker } from "./chat-pane-state.ts";
import {
@@ -62,6 +68,14 @@ import type { ChatSessionSharingState } from "./components/chat-session-sharing.
import { ChatTranscriptController } from "./components/chat-transcript-controller.ts";
import type { SessionDiscussionPanelConfig } from "./components/session-discussion-panel.ts";
import type { ChatMessageCache } from "./session-message-cache.ts";
import {
isSidebarRegionCollapsed,
sidebarPrimaryWidth,
type SidebarLayout,
} from "./sidebar-layout.ts";
const CHAT_RAIL_MAIN_MIN_WIDTH_PX = 312;
const CHAT_RAIL_DIVIDERS_MAX_WIDTH_PX = 8;
export abstract class ChatPaneBase extends OpenClawLightDomElement {
// Relative labels still need a minute tick; external PR state is server-pushed.
@@ -181,6 +195,103 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement {
@litState() protected resetConfirmationOpen = false;
@litState() protected sessionRailReady = customElements.get("openclaw-chat-session-rail") != null;
@litState() protected sessionRailMode: SessionRailMode = "hidden";
protected readonly companionRailLayout = new DockLayoutController(this, {
layout: chatCompanionRailLayout,
reservationPrefix: "chat-companion-rail",
isAvailable: () => true,
maxWidth: () => this.chatRailMaxWidth(".chat-main"),
reserveViewport: false,
});
protected readonly tasksRailLayout = new DockLayoutController(this, {
layout: chatTasksRailLayout,
reservationPrefix: "chat-tasks-rail",
isAvailable: () => true,
maxWidth: () =>
this.chatRailMaxWidth(
".chat-workbench",
".chat-workspace-rail",
"chat-workbench--workspace-open",
"chat-workbench--tasks-open",
),
reserveViewport: false,
});
protected readonly workspaceRailLayout = new DockLayoutController(this, {
layout: chatWorkspaceRailLayout,
reservationPrefix: "chat-workspace-rail",
isAvailable: () => true,
maxWidth: () =>
this.chatRailMaxWidth(
".chat-workbench",
".chat-tasks-rail",
"chat-workbench--tasks-open",
"chat-workbench--workspace-open",
),
reserveViewport: false,
});
protected companionRailColumn() {
return [
this.companionRailLayout.width,
this.companionRailLayout.renderResizer(
"chat-companion-rail",
t("chat.sidebarColumns.resize", { panel: t("chat.rail.title") }),
),
] as const;
}
protected tasksRailColumn() {
return [
this.tasksRailLayout.width,
this.tasksRailLayout.renderResizer(
"chat-tasks-rail",
t("chat.sidebarColumns.resize", { panel: t("chat.backgroundTasks.title") }),
),
] as const;
}
protected workspaceRailColumn() {
return [
this.workspaceRailLayout.width,
this.workspaceRailLayout.renderResizer(
"chat-workspace-rail",
t("chat.sidebarColumns.resize", { panel: t("chat.workspaceFiles.files") }),
),
] as const;
}
protected chatLayoutWidth(sidebarLayout: SidebarLayout): number {
const chatColumn = sidebarLayout.columns.find((column) =>
column.panels.some((panel) => panel.slot === "chat"),
);
return sidebarChatLayoutWidth(
this.paneWidth,
chatColumn?.width ?? sidebarPrimaryWidth(sidebarLayout, this.paneWidth),
isSidebarRegionCollapsed(sidebarLayout, this.paneWidth),
);
}
private chatRailMaxWidth(
containerSelector: string,
siblingSelector?: string,
siblingOpenClass?: string,
ownSideOpenClass?: string,
): number {
const container = this.querySelector<HTMLElement>(containerSelector);
const ownRailIsSideDocked =
!ownSideOpenClass || container?.classList.contains(ownSideOpenClass) === true;
const sideSibling =
ownRailIsSideDocked &&
siblingSelector &&
siblingOpenClass &&
container?.classList.contains(siblingOpenClass)
? this.querySelector<HTMLElement>(siblingSelector)
: null;
const containerWidth = container?.getBoundingClientRect().width ?? this.paneWidth;
const siblingWidth = sideSibling?.getBoundingClientRect().width ?? 0;
return (
containerWidth - siblingWidth - CHAT_RAIL_MAIN_MIN_WIDTH_PX - CHAT_RAIL_DIVIDERS_MAX_WIDTH_PX
);
}
protected sessionRailModeSessionKey = "";
protected sessionRailLoad: Promise<void> | null = null;
protected sessionRailCommand: (SessionRailCommand & { sessionKey: string }) | null = null;
+44 -44
View File
@@ -32,8 +32,8 @@ import {
} from "./chat-pane-session-controls.ts";
import {
SESSION_RAIL_SIDE_MIN_PANE_WIDTH,
WORKSPACE_RAIL_MAX_WIDTH,
WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH,
chatMainWidth,
} from "./chat-pane-shared.ts";
import {
renderSidebarRegion,
@@ -71,9 +71,7 @@ import {
activatePanel,
closeSlot,
detachPanelToColumn,
isSidebarRegionCollapsed,
mergePanelIntoColumn,
sidebarPrimaryWidth,
type SidebarSide,
type SidebarSlotId,
} from "./sidebar-layout.ts";
@@ -143,14 +141,12 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
const currentAgentId = resolveChatAgentId(state);
const { catalogKey, fullMessageLoader, chatProps } = resolveChatMessageAccess(state);
const overlays = this.context?.overlays;
const approvalSnapshot = overlays?.snapshot;
const inlineApproval = findInlineApproval(
approvalSnapshot?.approvalQueue ?? [],
overlays?.snapshot?.approvalQueue ?? [],
state.sessionKey,
);
// Tool rows consult the global title store while rendering; point its
// fetcher at this pane's connection. Requests capture session + agent at
// schedule time, so later renders of other panes cannot re-route them.
// Tool rows consult the global title store while rendering. Requests capture
// session + agent at schedule time, so another pane cannot re-route them.
configureToolTitleFetcher({
client: state.connected ? state.client : null,
sessionKey: catalogKey ? null : state.sessionKey || null,
@@ -209,21 +205,15 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
gatewaySnapshot.client?.instanceId,
state.sessionKey,
);
// Never flash "view-only" while metadata loads; after loading, anything short
// of a continuable session (failed lookups too) explains the disabled composer.
// Do not flash view-only while metadata loads; failed lookups still explain
// why the composer is disabled.
const catalogDisabledReason =
catalogKey && !this.catalogLoading && this.catalogSession?.canContinue !== true
? this.catalogHost?.kind === "node"
? t("chat.catalog.remoteViewOnly")
: t("chat.catalog.unsupportedViewOnly")
: null;
const sidebarChatColumn = sidebarLayout.columns.find((column) =>
column.panels.some((panel) => panel.slot === "chat"),
);
const sidebarRegionCollapsed = isSidebarRegionCollapsed(sidebarLayout, this.paneWidth);
const chatLayoutWidth = sidebarRegionCollapsed
? this.paneWidth
: (sidebarChatColumn?.width ?? sidebarPrimaryWidth(sidebarLayout, this.paneWidth));
const chatLayoutWidth = this.chatLayoutWidth(sidebarLayout);
const sessionWorkspace = createSessionWorkspaceProps(state, {
draftScope: this.presentationId,
narrowLayout: chatLayoutWidth < WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH,
@@ -232,11 +222,12 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
!sessionWorkspace.collapsed &&
!sessionWorkspace.narrowLayout &&
sessionWorkspace.dock !== "bottom";
// The workspace rail claims the first side slot; tasks need room for both columns.
// The workspace claims the first side slot; tasks need room for both columns.
const backgroundTasks = createBackgroundTasksProps(state, {
narrowLayout:
chatLayoutWidth <
WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH + (railSideDocked ? WORKSPACE_RAIL_MAX_WIDTH : 0),
WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH +
(railSideDocked ? this.workspaceRailLayout.width + 4 : 0),
openTaskId:
state.sidebarContent?.kind === "task" && detailSlotOpen(sidebarLayout)
? state.sidebarContent.taskId
@@ -244,9 +235,11 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
onOpenTaskDetail: (task) => state.handleOpenSidebar({ kind: "task", taskId: task.id }),
});
const tasksSideDocked = !backgroundTasks.collapsed && !backgroundTasks.narrowLayout;
// Only side-docked rails narrow the conversation region.
const sideRailCount = (railSideDocked ? 1 : 0) + (tasksSideDocked ? 1 : 0);
const chatMainWidth = chatLayoutWidth - sideRailCount * WORKSPACE_RAIL_MAX_WIDTH;
const conversationWidth = chatMainWidth(
chatLayoutWidth,
railSideDocked ? this.workspaceRailLayout.width : null,
tasksSideDocked ? this.tasksRailLayout.width : null,
);
const selfUser = resolveCurrentSelfUser({
snapshotUser: gatewaySnapshot.selfUser,
presenceEntries: readPresenceEntries(gatewaySnapshot.hello?.snapshot),
@@ -315,7 +308,8 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
: this.sessionCompanionThreads.view(state.sessionKey, currentAgentId),
...this.sessionRailCommandProps(state.sessionKey),
sessionRailMode: this.selectedSessionRailMode(state.sessionKey),
sessionRailDocked: !catalogKey && chatMainWidth >= SESSION_RAIL_SIDE_MIN_PANE_WIDTH,
sessionRailDocked: !catalogKey && conversationWidth >= SESSION_RAIL_SIDE_MIN_PANE_WIDTH,
companionRail: this.companionRailColumn(),
onSessionRailSubmit: (question) => void this.submitSessionCompanionQuestion(question),
onSessionRailDraftChange: (draft) =>
this.sessionCompanionThreads.setDraft(state.sessionKey, draft, currentAgentId),
@@ -326,8 +320,8 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
this.sessionRailMode = mode;
}
},
// Unconditional: catalog chats never render the rail (sessionRailReady is
// forced false), and a hide/show from any surface must reach the gateway.
// Catalog chats never render this rail, but hide/show from any surface must
// still reach the gateway.
onObserverVisibilityChange: this.setSessionObserverVisibility,
gatewayQuestionPrompts: catalogKey || sessionParticipationBlocked ? [] : this.questionPrompts,
onGatewayQuestionChange: () => {
@@ -398,9 +392,9 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
diskSpace,
runError: catalogKey ? null : (state.chatRunError ?? placementRunError),
inlineApproval: sessionParticipationBlocked ? null : inlineApproval,
approvalBusy: approvalSnapshot?.approvalBusy,
approvalErrors: approvalSnapshot?.approvalErrors,
approvalNowMs: approvalSnapshot?.approvalNowMs,
approvalBusy: overlays?.snapshot?.approvalBusy,
approvalErrors: overlays?.snapshot?.approvalErrors,
approvalNowMs: overlays?.snapshot?.approvalNowMs,
onApprovalDecision:
overlays && !sessionParticipationBlocked
? (approvalId, decision) => overlays.decideApproval(decision, approvalId)
@@ -442,17 +436,18 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
onModelSetup: () => this.context.navigate("model-setup"),
}),
sessionWorkspace: catalogKey ? undefined : sessionWorkspace,
workspaceRail: this.workspaceRailColumn(),
backgroundTasks: catalogKey ? undefined : backgroundTasks,
tasksRail: this.tasksRailColumn(),
taskSuggestions: this.taskSuggestions,
pullRequests: this.sessionPullRequests.filter(
(pullRequest) => !this.dismissedSessionPullRequestIds.has(chatPullRequestId(pullRequest)),
),
// Decided on the undismissed list: a dismissed open PR still exists, so
// the row must not offer creating a duplicate.
pullRequestsBranch: createPullRequestBranch(
this.sessionPullRequests,
this.sessionPullRequestsBranch,
),
// A dismissed open PR still exists, so the row must not offer a duplicate.
pullRequestsRateLimited: this.sessionPullRequestsRateLimited,
pullRequestsExpanded: this.sessionPullRequestsExpanded,
onExpandPullRequests: () => {
@@ -505,7 +500,8 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
},
onChatScroll: (event) => this.handleTranscriptScroll(event),
onHistoryIntent: (event) => this.handleTranscriptHistoryIntent(event),
// Metadata can resize a committed row; re-enter the scroll owner so the follow lock wins.
// Metadata can resize a committed row; re-enter the scroll owner so the
// follow lock wins.
onAssistantAttachmentLoaded: () => scheduleChatScroll(state),
getDraft: () => state.chatMessage,
onDraftChange: state.handleChatDraftChange,
@@ -612,8 +608,21 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
resolveArtifactDownload: (params) => resolveChatArtifactDownload(state, params),
basePath: state.basePath,
};
const chat = renderChat(props);
const primary = this.renderBoardPrimary(board, chat);
const header = this.renderPaneHeader(
sessionWorkspace,
backgroundTasks,
selectedSession,
Boolean(catalogKey),
selectedAgent?.workspace,
selectedAgent?.workspaceGit === true,
);
const chat = renderChat({ ...props, header: board.face === "dashboard" ? nothing : header });
const boardPrimary = this.renderBoardPrimary(board, chat);
// Keep this root stable across board face changes so the guarded board runtime
// remains connected while Chat is active.
const primary = html`<div class="chat-pane-primary-column">
${board.face === "dashboard" ? header : nothing}${boardPrimary}
</div>`;
const discussion = this.buildSessionDiscussionPanel(state, state.sessionKey.trim());
const panelTemplates = {
chat,
@@ -691,21 +700,12 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
focusVersion: state.sidebarFocusVersion,
layout: sidebarLayout,
narrow: this.paneWidth < SIDEBAR_NARROW_BREAKPOINT_PX,
panelMutationEnabled: {
chat: Boolean(board.activeTabId) && board.provider.canMutate,
},
panelMutationEnabled: { chat: Boolean(board.activeTabId) && board.provider.canMutate },
panelTemplates,
primary,
sessionKey: state.sessionKey,
});
return html`${this.renderPaneHeader(
sessionWorkspace,
backgroundTasks,
selectedSession,
Boolean(catalogKey),
selectedAgent?.workspace,
selectedAgent?.workspaceGit === true,
)}${content}${renderChatImageLightbox(
return html`${content}${renderChatImageLightbox(
state.imageLightbox,
state.handleCloseImage,
)}${this.renderResetConfirmation()}`;
+50 -6
View File
@@ -166,6 +166,36 @@ export const boardChatDockLayout = createDockPanelLayout({
defaultHeight: 320,
defaultWidth: 420,
});
export const chatWorkspaceRailLayout = createDockPanelLayout({
storageKey: "openclaw.control.chat-workspace-rail.v1",
minHeight: 180,
minWidth: 260,
defaultDock: "right",
supportedDocks: ["right"],
defaultHeight: 320,
defaultWidth: 280,
});
export const chatTasksRailLayout = createDockPanelLayout({
storageKey: "openclaw.control.chat-tasks-rail.v1",
minHeight: 180,
minWidth: 270,
defaultDock: "right",
supportedDocks: ["right"],
defaultHeight: 320,
defaultWidth: 330,
});
export const chatCompanionRailLayout = createDockPanelLayout({
storageKey: "openclaw.control.chat-companion-rail.v1",
minHeight: 180,
minWidth: 300,
defaultDock: "right",
supportedDocks: ["right"],
defaultHeight: 320,
defaultWidth: 400,
});
export const CATALOG_TOOL_RESULT_PREVIEW_MAX_CHARS = 500;
export const CHAT_HISTORY_INTENT_EDGE_PX = 300;
export const CHAT_HISTORY_INTENT_IDLE_MS = 200;
@@ -245,12 +275,8 @@ export const CHAT_HISTORY_BOOTSTRAP_PAGE_LIMIT = 1;
* measured width, never viewport media queries. */
// Side rail (230-280px) plus a readable thread; below this the rail docks bottom.
export const WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH = 800;
// Widest the rail's grid column gets; a side-docked rail takes this from the
// width available to the chat + detail-panel split.
export const WORKSPACE_RAIL_MAX_WIDTH = 280;
// Widest the session companion's docked column gets; keep in sync with the
// flex-basis in chat/sidebar.css. Wider than the workspace rail because it
// hosts a reading surface, not a file list.
// The companion defaults wider than the workspace rail because it hosts a
// reading surface, not a file list.
const SESSION_RAIL_MAX_WIDTH = 400;
// The companion is a side surface, not an overlay: it docks whenever its column
// and a readable thread both fit. Measured against the width left after the
@@ -258,6 +284,24 @@ const SESSION_RAIL_MAX_WIDTH = 400;
// columns, so the companion becomes a full-height sheet instead of covering
// the thread as a floating card.
export const SESSION_RAIL_SIDE_MIN_PANE_WIDTH = SESSION_RAIL_MAX_WIDTH + 480;
export function sidebarChatLayoutWidth(
paneWidth: number,
sidebarChatWidth: number | undefined,
collapsed: boolean,
): number {
return collapsed ? paneWidth : (sidebarChatWidth ?? paneWidth);
}
export function chatMainWidth(
layoutWidth: number,
workspaceWidth: number | null,
tasksWidth: number | null,
): number {
return (
layoutWidth -
(workspaceWidth === null ? 0 : workspaceWidth + 4) -
(tasksWidth === null ? 0 : tasksWidth + 4)
);
}
export const NEW_SESSION_ACTIVE_RUN_MESSAGE =
"Start a new session after the active run or queued messages finish.";
export const NEW_SESSION_LIST_LOADING_MESSAGE =
@@ -367,7 +367,7 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) {
<main class="content content--chat">
<section class="card chat">
<div class="chat-split-container">
<div class="chat-main${opts.sessionRailDocked ? " chat-main--rail-docked" : ""}" style="flex: 1 1 100%">
<div class="chat-main${opts.sessionRailDocked ? " chat-main--rail-docked" : ""}" style="--chat-companion-rail-width: 400px; flex: 1 1 100%">
<div class="chat-thread${opts.direct ? " chat-thread--direct" : ""}" role="log">
<div class="chat-thread-inner">
<div class="chat-group user">
@@ -760,8 +760,8 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
expect(icons).toHaveLength(2);
for (const icon of icons) {
const box = await icon.boundingBox();
expect(box?.width).toBe(16);
expect(box?.height).toBe(16);
expect(box?.width).toBeCloseTo(16, 3);
expect(box?.height).toBeCloseTo(16, 3);
}
await input.focus();
const outline = await input.evaluate((element) => {
+42 -9
View File
@@ -821,11 +821,8 @@ function createSessionWorkspace(
activeId: null,
dock: "right",
narrowLayout: false,
dockDragging: false,
dockDragZone: null,
onToggleCollapsed: () => undefined,
onSetDock: () => undefined,
onDockDragStart: () => undefined,
onRefresh: () => undefined,
onBrowsePath: () => undefined,
onCopyPath: () => undefined,
@@ -1111,7 +1108,7 @@ describe("cloud worker disk-space notice", () => {
title: "Cloud session disk space is critically low",
copy: "New writes may fail and stop the agent.",
},
])("renders persistent $status action guidance above the workbench", (sample) => {
])("renders persistent $status action guidance above the conversation", (sample) => {
const container = renderChatView({
diskSpace: {
status: sample.status,
@@ -1127,7 +1124,8 @@ describe("cloud worker disk-space notice", () => {
expect(notice.textContent).toContain(sample.copy);
expect(notice.querySelector("svg")).not.toBeNull();
expect(notice.querySelector("button")).toBeNull();
expect(notice.nextElementSibling?.classList.contains("chat-workbench")).toBe(true);
expect(notice.parentElement?.classList.contains("chat-main__conversation-column")).toBe(true);
expect(notice.nextElementSibling?.classList.contains("chat-main__conversation")).toBe(true);
});
it.each(["ok" as const, undefined])("clears for %s disk-space projection", (status) => {
@@ -1841,8 +1839,8 @@ describe("chat composer workbench", () => {
expect(main?.parentElement).toBe(workbench);
expect(rail?.parentElement).toBe(workbench);
expect(Array.from(workbench?.children ?? []).map((child) => child.className)).toEqual([
"chat-workspace-rail",
"chat-workbench__main",
"chat-workspace-rail",
]);
expect(container.querySelector(".chat-workspace-rail__path")?.textContent?.trim()).toBe(
"/workspace",
@@ -1910,7 +1908,7 @@ describe("chat composer workbench", () => {
openSpy.mockRestore();
});
it("forces the workspace rail to the bottom dock and drops side-dock controls on narrow panes", () => {
it("forces the workspace rail to the bottom dock on narrow panes", () => {
const container = renderChatView({
sessionWorkspace: createSessionWorkspace({
narrowLayout: true,
@@ -1925,8 +1923,43 @@ 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();
expect(container.querySelector('button[aria-label="Toggle terminal"]')).not.toBeNull();
expect(container.querySelector('button[aria-label="Toggle browser panel"]')).not.toBeNull();
expect(container.querySelector(".chat-session-diff-toggle")).not.toBeNull();
});
it("keeps a preferred bottom workspace below a side-docked Tasks rail", () => {
const container = renderChatView({
sessionWorkspace: createSessionWorkspace({ dock: "bottom" }),
workspaceRail: [820, html`<div class="chat-workspace-rail-resizer"></div>`],
backgroundTasks: createBackgroundTasks(),
tasksRail: [330, html`<div class="chat-tasks-rail-resizer"></div>`],
});
const workbench = container.querySelector(".chat-workbench");
expect(workbench?.classList.contains("chat-workbench--dock-bottom")).toBe(true);
expect(workbench?.classList.contains("chat-workbench--workspace-open")).toBe(false);
expect(workbench?.classList.contains("chat-workbench--tasks-open")).toBe(true);
expect(workbench?.querySelector(".chat-workspace-rail-resizer")).toBeNull();
expect(workbench?.querySelector(".chat-tasks-rail-resizer")).not.toBeNull();
expect(
(workbench as HTMLElement | null)?.style.getPropertyValue("--chat-workspace-rail-width"),
).toBe("820px");
});
it("keeps the pane header in the conversation column while rails span the workbench", () => {
const container = renderChatView({
header: html`<header class="chat-pane__header">Session</header>`,
sessionWorkspace: createSessionWorkspace(),
workspaceRail: [344, html`<div class="chat-workspace-rail-resizer"></div>`],
});
const workbench = container.querySelector<HTMLElement>(".chat-workbench");
const column = container.querySelector(".chat-main__conversation-column");
expect(column?.firstElementChild?.classList.contains("chat-pane__header")).toBe(true);
expect(container.querySelector(".chat-workspace-rail")?.contains(column)).toBe(false);
expect(workbench?.style.getPropertyValue("--chat-workspace-rail-width")).toBe("344px");
expect(workbench?.querySelector(".chat-workspace-rail-resizer")).not.toBeNull();
});
it("moves the background-tasks rail to a bottom strip on narrow panes", () => {
+74 -77
View File
@@ -45,7 +45,11 @@ import type {
ChatQueuedEditProps,
} from "./components/chat-composer-types.ts";
import { isChatRunWorking, renderChatComposer } from "./components/chat-composer.ts";
import { inlineChatImageFromEvent, openInlineChatImage } from "./components/chat-image-lightbox.ts";
import {
inlineChatImageFromEvent,
isImageLightboxEvent,
openInlineChatImage,
} from "./components/chat-image-lightbox.ts";
import type { ArtifactDownloadResolver } from "./components/chat-message-media.ts";
import { renderChatPullRequests } from "./components/chat-pull-requests.ts";
import type { SessionRailCommand, SessionRailMode } from "./components/chat-session-rail.ts";
@@ -74,14 +78,13 @@ import type { ChatRunUiStatus } from "./run-lifecycle.ts";
import type { CompactionStatus, FallbackStatus, PlanStatus } from "./tool-stream.ts";
import type { WorkspaceResultConflict } from "./workspace-conflict.ts";
import "../../components/resizable-divider.ts";
type ChatReplyTarget = {
messageId: string;
text: string;
senderLabel?: string | null;
sourceMessageId?: string | null;
};
type ChatRailColumn = readonly [width: number, resizer: TemplateResult | typeof nothing];
export type ChatProps = ChatTaskSuggestionTrayProps &
ChatCloudStartupNoticeProps & {
transcript: ChatTranscriptController;
@@ -113,6 +116,7 @@ export type ChatProps = ChatTaskSuggestionTrayProps &
sessionRailConsumedCommandGeneration?: number;
sessionRailMode?: SessionRailMode;
sessionRailDocked?: boolean;
companionRail?: ChatRailColumn;
onSessionRailCommandConsumed?: (generation: number) => void;
onSessionRailSubmit?: (question: string) => void;
onSessionRailDraftChange?: (draft: string) => void;
@@ -265,7 +269,10 @@ export type ChatProps = ChatTaskSuggestionTrayProps &
onRewindMessage?: (entryId: string) => Promise<boolean> | boolean;
onForkMessage?: (entryId: string) => Promise<void> | void;
sessionWorkspace?: SessionWorkspaceProps;
workspaceRail?: ChatRailColumn;
backgroundTasks?: BackgroundTasksProps;
tasksRail?: ChatRailColumn;
header?: TemplateResult | typeof nothing;
sessionSuggestions?: readonly SessionSuggestion[];
sessionSuggestionRole?: SessionSharingRole;
sessionSuggestionBusyIds?: ReadonlySet<string>;
@@ -283,14 +290,6 @@ export type ChatProps = ChatTaskSuggestionTrayProps &
onDismissPullRequest?: (pullRequest: ControlUiSessionPullRequest) => void;
};
function isImageLightboxEvent(event: Event): boolean {
return event
.composedPath()
.some(
(target) => target instanceof HTMLElement && target.localName === "openclaw-image-lightbox",
);
}
export function renderChat(props: ChatProps) {
const requestUpdate = props.onRequestUpdate ?? (() => {});
const workspaceCollapsed = props.sessionWorkspace?.collapsed !== false;
@@ -300,6 +299,8 @@ export function renderChat(props: ChatProps) {
);
const tasksOpen = props.backgroundTasks?.collapsed === false;
const tasksDockBottom = tasksOpen && props.backgroundTasks?.narrowLayout === true;
const workspaceSideOpen = !workspaceCollapsed && !workspaceDockBottom;
const tasksSideOpen = tasksOpen && !tasksDockBottom;
const canCompose = props.canSend;
const showModelSetupSplash =
props.modelSetupRequired === true &&
@@ -535,84 +536,76 @@ export function renderChat(props: ChatProps) {
}
}}
>
${renderChatViewNotices(props)} ${renderTranscriptSearch(props.paneId, requestUpdate)}
<div
class="chat-workbench ${workspaceCollapsed
? "chat-workbench--workspace-collapsed"
: ""} ${workspaceDockBottom ? "chat-workbench--dock-bottom" : ""} ${tasksOpen &&
!tasksDockBottom
? "chat-workbench--tasks-open"
: ""} ${tasksDockBottom ? "chat-workbench--tasks-dock-bottom" : ""}"
: ""} ${workspaceDockBottom ? "chat-workbench--dock-bottom" : ""} ${workspaceSideOpen
? "chat-workbench--workspace-open"
: ""} ${tasksSideOpen ? "chat-workbench--tasks-open" : ""} ${tasksDockBottom
? "chat-workbench--tasks-dock-bottom"
: ""}"
style=${styleMap({
"--chat-workspace-rail-width": `${props.workspaceRail?.[0] ?? 280}px`,
"--chat-tasks-rail-width": `${props.tasksRail?.[0] ?? 330}px`,
})}
>
${renderSessionWorkspaceRail(props.sessionWorkspace)}
${renderBackgroundTasksRail(props.backgroundTasks)}
${props.sessionWorkspace?.dockDragging
? html`
<div class="chat-workbench__dock-zones" aria-hidden="true">
<div
class="chat-workbench__dock-zone chat-workbench__dock-zone--right ${props
.sessionWorkspace.dockDragZone === "right"
? "chat-workbench__dock-zone--active"
: ""}"
>
<span>${t("chat.workspaceFiles.dockRight")}</span>
</div>
<div
class="chat-workbench__dock-zone chat-workbench__dock-zone--bottom ${props
.sessionWorkspace.dockDragZone === "bottom"
? "chat-workbench__dock-zone--active"
: ""}"
>
<span>${t("chat.workspaceFiles.dockBottom")}</span>
</div>
</div>
`
: nothing}
<div class="chat-workbench__main">
<div class="chat-split-container">
<div
class="chat-main ${props.sessionRailDocked && props.sessionRailMode === "expanded"
? "chat-main--rail-docked"
: ""}"
style=${styleMap({
"--chat-companion-rail-width": `${props.companionRail?.[0] ?? 400}px`,
})}
>
<div class="chat-main__conversation">
${thread} ${scrollToBottomButton}
${props.inlineApproval && props.onApprovalDecision
? html`<div class="chat-inline-approval">
${renderExecApprovalCard({
approval: props.inlineApproval,
busy: props.approvalBusy === true,
error: props.approvalErrors?.get(props.inlineApproval.id) ?? null,
nowMs: props.approvalNowMs ?? Date.now(),
variant: "inline",
onDecision: props.onApprovalDecision,
})}
</div>`
: nothing}
${renderChatTaskSuggestionTray(props)}
${renderChatPullRequests({
pullRequests: props.pullRequests ?? [],
branch: props.pullRequestsBranch,
rateLimited: props.pullRequestsRateLimited === true,
expanded: props.pullRequestsExpanded === true,
onExpand: () => props.onExpandPullRequests?.(),
onDismiss: (pullRequest) => props.onDismissPullRequest?.(pullRequest),
})}
${renderChatSessionSuggestions({
suggestions: props.sessionSuggestions ?? [],
role: props.sessionSuggestionRole,
busyIds: props.sessionSuggestionBusyIds ?? new Set(),
archived: props.sessionSuggestionsArchived === true,
canResolve: props.canResolveSessionSuggestions === true,
onResolve: (suggestion, resolution) =>
props.onResolveSessionSuggestion?.(suggestion, resolution),
})}
${renderChatSwarmProgress({
sessions: props.swarmSessions ?? [],
sessionKey: props.sessionKey,
})}
${showModelSetupSplash ? nothing : chatColumnFooter}
<div class="chat-main__conversation-column">
${props.header ?? nothing} ${renderChatViewNotices(props)}
${renderTranscriptSearch(props.paneId, requestUpdate)}
<div class="chat-main__conversation">
${thread} ${scrollToBottomButton}
${props.inlineApproval && props.onApprovalDecision
? html`<div class="chat-inline-approval">
${renderExecApprovalCard({
approval: props.inlineApproval,
busy: props.approvalBusy === true,
error: props.approvalErrors?.get(props.inlineApproval.id) ?? null,
nowMs: props.approvalNowMs ?? Date.now(),
variant: "inline",
onDecision: props.onApprovalDecision,
})}
</div>`
: nothing}
${renderChatTaskSuggestionTray(props)}
${renderChatPullRequests({
pullRequests: props.pullRequests ?? [],
branch: props.pullRequestsBranch,
rateLimited: props.pullRequestsRateLimited === true,
expanded: props.pullRequestsExpanded === true,
onExpand: () => props.onExpandPullRequests?.(),
onDismiss: (pullRequest) => props.onDismissPullRequest?.(pullRequest),
})}
${renderChatSessionSuggestions({
suggestions: props.sessionSuggestions ?? [],
role: props.sessionSuggestionRole,
busyIds: props.sessionSuggestionBusyIds ?? new Set(),
archived: props.sessionSuggestionsArchived === true,
canResolve: props.canResolveSessionSuggestions === true,
onResolve: (suggestion, resolution) =>
props.onResolveSessionSuggestion?.(suggestion, resolution),
})}
${renderChatSwarmProgress({
sessions: props.swarmSessions ?? [],
sessionKey: props.sessionKey,
})}
${showModelSetupSplash ? nothing : chatColumnFooter}
</div>
</div>
${props.sessionRailReady &&
props.sessionRailDocked &&
props.sessionRailMode === "expanded"
? (props.companionRail?.[1] ?? nothing)
: nothing}
${props.sessionRailReady
? html`
<openclaw-chat-session-rail
@@ -640,6 +633,10 @@ export function renderChat(props: ChatProps) {
</div>
</div>
</div>
${workspaceSideOpen ? (props.workspaceRail?.[1] ?? nothing) : nothing}
${renderSessionWorkspaceRail(props.sessionWorkspace)}
${tasksSideOpen ? (props.tasksRail?.[1] ?? nothing) : nothing}
${renderBackgroundTasksRail(props.backgroundTasks)}
</div>
</section>
`;
@@ -71,7 +71,7 @@ export function renderBackgroundTasksRail(
<openclaw-tooltip .content=${t("chat.backgroundTasks.collapse")}>
<button
type="button"
class="nav-collapse-toggle chat-tasks-rail__collapse-toggle"
class="rail-header__action chat-tasks-rail__collapse-toggle"
aria-label=${t("chat.backgroundTasks.collapse")}
aria-expanded="true"
@click=${backgroundTasks.onToggleCollapsed}
@@ -88,15 +88,17 @@ export function renderBackgroundTasksRail(
class="chat-tasks-rail"
aria-label=${t("chat.backgroundTasks.label")}
>
<div class="chat-tasks-rail__header">
<div class="chat-tasks-rail__title">
<span class="chat-tasks-rail__eyebrow">${backgroundTasks.sessionKey}</span>
<strong>${t("chat.backgroundTasks.title")}</strong>
<div class="rail-header chat-tasks-rail__header">
<div class="rail-header__copy chat-tasks-rail__title">
<span class="rail-header__eyebrow chat-tasks-rail__eyebrow"
>${backgroundTasks.sessionKey}</span
>
<strong class="rail-header__title">${t("chat.backgroundTasks.title")}</strong>
</div>
<div class="chat-tasks-rail__actions">
<div class="rail-header__actions chat-tasks-rail__actions">
<openclaw-tooltip .content=${t("chat.backgroundTasks.refresh")}>
<button
class="btn btn--ghost btn--sm chat-tasks-rail__refresh"
class="rail-header__action chat-tasks-rail__refresh"
type="button"
aria-label=${t("chat.backgroundTasks.refresh")}
?disabled=${backgroundTasks.loading || !backgroundTasks.connected}
@@ -4,6 +4,14 @@ import type { ImageLightboxItem } from "../../../components/image-lightbox.ts";
import { t } from "../../../i18n/index.ts";
import { openExternalUrlSafe } from "../../../lib/open-external-url.ts";
export function isImageLightboxEvent(event: Event): boolean {
return event
.composedPath()
.some(
(target) => target instanceof HTMLElement && target.localName === "openclaw-image-lightbox",
);
}
export function inlineChatImageFromEvent(event: Event): HTMLImageElement | null {
const target = event
.composedPath()
@@ -567,7 +567,7 @@ export class ChatSessionRailElement extends OpenClawLightDomElement {
<button
class="btn btn--ghost btn--icon chat-icon-btn chat-session-rail__hide"
type="button"
aria-label=${t("chat.rail.hide")}
aria-label=${t("chat.rail.close")}
@click=${() => this.hide()}
>
${icons.x}
@@ -609,8 +609,8 @@ export class ChatSessionRailElement extends OpenClawLightDomElement {
}
}}
>
<header class="chat-session-rail__header">
<div class="chat-session-rail__header-copy">
<header class="rail-header chat-session-rail__header">
<div class="rail-header__copy chat-session-rail__header-copy">
<div class="chat-session-rail__status-row">
${digest ? this.renderStatus(digest) : html`<strong>${t("chat.rail.title")}</strong>`}
${elapsed
@@ -623,7 +623,7 @@ export class ChatSessionRailElement extends OpenClawLightDomElement {
? html`<strong class="chat-session-rail__headline">${digest.headline}</strong>`
: html`<span class="chat-session-rail__subtitle">${t("chat.rail.subtitle")}</span>`}
</div>
<div class="chat-session-rail__actions">
<div class="rail-header__actions chat-session-rail__actions">
<wa-dropdown
class="chat-session-rail__menu"
placement="bottom-end"
@@ -635,7 +635,7 @@ export class ChatSessionRailElement extends OpenClawLightDomElement {
>
<button
slot="trigger"
class="btn btn--ghost btn--icon chat-icon-btn"
class="rail-header__action"
type="button"
aria-label=${t("chat.rail.moreActions")}
aria-haspopup="menu"
@@ -651,15 +651,15 @@ export class ChatSessionRailElement extends OpenClawLightDomElement {
</wa-dropdown-item>
</wa-dropdown>
<button
class="btn btn--ghost btn--icon chat-icon-btn chat-session-rail__hide"
class="rail-header__action chat-session-rail__hide"
type="button"
aria-label=${t("chat.rail.hide")}
aria-label=${t("chat.rail.close")}
@click=${() => this.hide()}
>
${icons.x}
</button>
<button
class="btn btn--ghost btn--icon chat-icon-btn chat-session-rail__toggle"
class="rail-header__action chat-session-rail__toggle"
type="button"
aria-label=${t("chat.rail.collapse")}
@click=${() => this.collapse()}
@@ -41,6 +41,26 @@ describe("toggleSessionWorkspace", () => {
expect(createSessionWorkspaceProps(state).collapsed).toBe(true);
expect(requestUpdate).toHaveBeenCalledTimes(2);
});
it("carries the saved bottom dock across session workspace state", () => {
const state = {
client: null,
connected: false,
handleOpenSidebar: vi.fn(),
hello: null,
requestUpdate: vi.fn(),
sessionKey: "agent:main:current",
settings: { chatWorkspaceDock: "bottom" },
sessions: {},
} as unknown as SessionWorkspaceHost;
const workspace = createSessionWorkspaceProps(state);
expect(workspace.dock).toBe("bottom");
workspace.onSetDock("right");
expect(createSessionWorkspaceProps(state).dock).toBe("right");
expect(state.settings?.chatWorkspaceDock).toBe("right");
});
});
describe("session workspace artifacts", () => {
@@ -51,11 +51,8 @@ export type SessionWorkspaceProps = {
/** Pane too narrow for a side rail: presentation forces the bottom dock
* (the persisted dock preference still applies once the pane widens). */
narrowLayout: boolean;
dockDragging: boolean;
dockDragZone: ChatWorkspaceDock | null;
onToggleCollapsed: () => void;
onSetDock: (dock: ChatWorkspaceDock) => void;
onDockDragStart: (event: PointerEvent) => void;
onRefresh: () => void;
onBrowsePath: (path: string) => void;
onCopyPath: (path: string) => void;
@@ -64,6 +61,7 @@ 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;
};
@@ -76,8 +74,6 @@ type SessionWorkspaceState = {
browserSearchTimer: ReturnType<typeof globalThis.setTimeout> | null;
collapsed: boolean;
dock: ChatWorkspaceDock;
dockDragging: boolean;
dockDragZone: ChatWorkspaceDock | null;
error: string | null;
list: SessionWorkspaceListResult | null;
loading: boolean;
@@ -160,8 +156,6 @@ function getWorkspaceState(state: SessionWorkspaceHost): SessionWorkspaceState {
// Dock preference is app-wide, seeded from the host's loaded settings;
// per-session state just carries it forward.
dock: current?.dock ?? normalizeChatWorkspaceDock(state.settings?.chatWorkspaceDock),
dockDragging: false,
dockDragZone: null,
error: null,
list: null,
loading: false,
@@ -627,8 +621,6 @@ function setSessionWorkspaceDock(state: SessionWorkspaceHost, dock: ChatWorkspac
const workspace = getWorkspaceState(state);
if (workspace.dock !== dock) {
workspace.dock = dock;
// Keep the host's settings snapshot in step so the next session's
// workspace state seeds from the same dock without a storage read.
if (state.settings) {
state.settings = { ...state.settings, chatWorkspaceDock: dock };
}
@@ -637,74 +629,6 @@ function setSessionWorkspaceDock(state: SessionWorkspaceHost, dock: ChatWorkspac
requestUpdate(state);
}
/** Drag the rail by its header to re-dock it inside the pane: the right and
* bottom bands of .chat-workbench are drop zones (mirrors the terminal
* panel's right/bottom dock). A small threshold keeps plain clicks intact. */
function startSessionWorkspaceDockDrag(state: SessionWorkspaceHost, event: PointerEvent) {
if (event.button !== 0) {
return;
}
const grip = event.currentTarget;
if (!(grip instanceof HTMLElement)) {
return;
}
const workbench = grip.closest<HTMLElement>(".chat-workbench");
if (!workbench) {
return;
}
const workspace = getWorkspaceState(state);
const startX = event.clientX;
const startY = event.clientY;
const resolveZone = (x: number, y: number): ChatWorkspaceDock | null => {
const rect = workbench.getBoundingClientRect();
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
return null;
}
if (y > rect.bottom - rect.height * 0.32) {
return "bottom";
}
return x > rect.right - rect.width * 0.3 ? "right" : null;
};
const handleMove = (move: PointerEvent) => {
if (!workspace.dockDragging) {
if (Math.hypot(move.clientX - startX, move.clientY - startY) < 5) {
return;
}
workspace.dockDragging = true;
workspace.dockDragZone = resolveZone(move.clientX, move.clientY);
requestUpdate(state);
return;
}
const zone = resolveZone(move.clientX, move.clientY);
if (zone !== workspace.dockDragZone) {
workspace.dockDragZone = zone;
requestUpdate(state);
}
};
const finish = (apply: boolean) => {
grip.removeEventListener("pointermove", handleMove);
grip.removeEventListener("pointerup", handleUp);
grip.removeEventListener("pointercancel", handleCancel);
const zone = workspace.dockDragZone;
workspace.dockDragging = false;
workspace.dockDragZone = null;
if (apply && zone) {
setSessionWorkspaceDock(state, zone);
return;
}
requestUpdate(state);
};
const handleUp = () => finish(true);
const handleCancel = () => finish(false);
grip.setPointerCapture(event.pointerId);
grip.addEventListener("pointermove", handleMove);
grip.addEventListener("pointerup", handleUp);
grip.addEventListener("pointercancel", handleCancel);
}
export function revealSessionWorkspaceFile(state: SessionWorkspaceHost, path: string) {
const workspace = getWorkspaceState(state);
clearWorkspaceSearchTimer(workspace);
@@ -779,11 +703,8 @@ export function createSessionWorkspaceProps(
activeId: workspace.activeId,
dock: workspace.dock,
narrowLayout: options?.narrowLayout === true,
dockDragging: workspace.dockDragging,
dockDragZone: workspace.dockDragZone,
onToggleCollapsed: () => toggleSessionWorkspace(state),
onSetDock: (dock) => setSessionWorkspaceDock(state, dock),
onDockDragStart: (event) => startSessionWorkspaceDockDrag(state, event),
onRefresh: () => loadWorkspace(state, workspace, true),
onBrowsePath: (path) => {
clearWorkspaceSearchTimer(workspace);
@@ -981,6 +902,62 @@ 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="rail-header__action 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="rail-header__action 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="rail-header__action 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="rail-header__action 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");
@@ -1256,20 +1233,15 @@ export function renderSessionWorkspaceRail(
`;
return html`
<aside class="chat-workspace-rail" aria-label=${t("chat.workspaceFiles.label")}>
<div class="chat-workspace-rail__header">
<!-- Grip: drag the rail onto the pane's right/bottom band to re-dock
it (chat-view renders the drop zones while dragging). -->
<div
class="chat-workspace-rail__title ${sessionWorkspace.narrowLayout
? ""
: "chat-workspace-rail__grip"}"
title=${sessionWorkspace.narrowLayout ? nothing : t("chat.workspaceFiles.dragToDock")}
@pointerdown=${sessionWorkspace.narrowLayout ? nothing : sessionWorkspace.onDockDragStart}
>
<span class="chat-workspace-rail__eyebrow">${t("chat.workspaceFiles.workspace")}</span>
<strong>${t("chat.workspaceFiles.files")}</strong>
<div class="rail-header chat-workspace-rail__header">
<div class="rail-header__copy chat-workspace-rail__title">
<span class="rail-header__eyebrow chat-workspace-rail__eyebrow"
>${t("chat.workspaceFiles.workspace")}</span
>
<strong class="rail-header__title">${t("chat.workspaceFiles.files")}</strong>
</div>
<div class="chat-workspace-rail__actions">
<div class="rail-header__actions chat-workspace-rail__actions">
${diffButton} ${terminalButton} ${browserButton} ${custodianButton}
${sessionWorkspace.narrowLayout
? nothing
: html`
@@ -1279,7 +1251,7 @@ export function renderSessionWorkspaceRail(
: t("chat.workspaceFiles.dockBottom")}
>
<button
class="btn btn--ghost btn--sm chat-workspace-rail__dock"
class="rail-header__action chat-workspace-rail__dock"
type="button"
aria-label=${dock === "bottom"
? t("chat.workspaceFiles.dockRight")
@@ -1293,7 +1265,7 @@ export function renderSessionWorkspaceRail(
`}
<openclaw-tooltip .content=${t("chat.workspaceFiles.refresh")}>
<button
class="btn btn--ghost btn--sm chat-workspace-rail__refresh"
class="rail-header__action chat-workspace-rail__refresh"
type="button"
aria-label=${t("chat.workspaceFiles.refresh")}
?disabled=${sessionWorkspace.loading}
@@ -1305,7 +1277,7 @@ export function renderSessionWorkspaceRail(
<openclaw-tooltip .content=${`${t("chat.workspaceFiles.collapse")} (⇧⌘B)`}>
<button
type="button"
class="nav-collapse-toggle chat-workspace-rail__collapse-toggle"
class="rail-header__action chat-workspace-rail__collapse-toggle"
aria-label=${t("chat.workspaceFiles.collapse")}
aria-keyshortcuts="Meta+Shift+B"
aria-expanded="true"
@@ -165,43 +165,61 @@ class ChatSidebarRegion extends OpenClawLightDomElement {
return nothing;
}
const openUrl = this.panelOpenUrls[active.slot];
const singlePanel = column.panels.length === 1 ? active : null;
const singlePanelDraggable =
singlePanel != null && !narrow && this.canMutatePanel(singlePanel.slot);
return html`
<div
class="sidebar-column__header"
class="rail-header sidebar-column__header"
@dragover=${(event: DragEvent) => (narrow ? undefined : this.allowPanelDrop(event))}
@drop=${(event: DragEvent) => (narrow ? undefined : this.dropOnHeader(event, column))}
>
<wa-tab-group
class="sidebar-column__tabs"
.active=${active.id}
activation="auto"
without-scroll-controls
@wa-tab-show=${(event: CustomEvent<{ name: string }>) => this.activate(event.detail.name)}
>
${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=${draggable}
title=${draggable
? t("chat.sidebarColumns.drag", { panel: panelTitle(panel.slot) })
: nothing}
@dragstart=${(event: DragEvent) =>
draggable ? this.startDrag(event, panel.id) : undefined}
@dragend=${() => this.endDrag()}
>
${panelTitle(panel.slot)}
</wa-tab>
`;
})}
</wa-tab-group>
<div class="sidebar-column__actions">
${singlePanel
? html`<div
class="rail-header__copy sidebar-column__single-panel sidebar-column__tab"
data-panel-id=${singlePanel.id}
.draggable=${singlePanelDraggable}
title=${singlePanelDraggable
? t("chat.sidebarColumns.drag", { panel: panelTitle(singlePanel.slot) })
: nothing}
@dragstart=${(event: DragEvent) =>
singlePanelDraggable ? this.startDrag(event, singlePanel.id) : undefined}
@dragend=${() => this.endDrag()}
>
<strong class="rail-header__title">${panelTitle(singlePanel.slot)}</strong>
</div>`
: html`<wa-tab-group
class="sidebar-column__tabs"
.active=${active.id}
activation="auto"
without-scroll-controls
@wa-tab-show=${(event: CustomEvent<{ name: string }>) =>
this.activate(event.detail.name)}
>
${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=${draggable}
title=${draggable
? t("chat.sidebarColumns.drag", { panel: panelTitle(panel.slot) })
: nothing}
@dragstart=${(event: DragEvent) =>
draggable ? this.startDrag(event, panel.id) : undefined}
@dragend=${() => this.endDrag()}
>
${panelTitle(panel.slot)}
</wa-tab>
`;
})}
</wa-tab-group>`}
<div class="rail-header__actions sidebar-column__actions">
${openUrl
? html`<a
class="btn btn--ghost btn--icon"
class="rail-header__action"
href=${openUrl}
target="_blank"
rel="noopener"
@@ -212,7 +230,7 @@ class ChatSidebarRegion extends OpenClawLightDomElement {
: nothing}
${this.canMutatePanel(active.slot)
? html`<button
class="btn btn--ghost btn--icon"
class="rail-header__action"
type="button"
aria-label=${t("chat.sidebarColumns.close", { panel: panelTitle(active.slot) })}
title=${t("chat.sidebarColumns.close", { panel: panelTitle(active.slot) })}
@@ -97,6 +97,12 @@ describe("chat sidebar region", () => {
tab.textContent?.trim(),
),
).toEqual(["Chat", "Details", "Discussion"]);
expect(regionRoot(region).querySelectorAll(".sidebar-column__tabs")).toHaveLength(0);
expect(
Array.from(regionRoot(region).querySelectorAll(".rail-header__title"), (title) =>
title.textContent?.trim(),
),
).toEqual(["Chat", "Details", "Discussion"]);
expect(regionRoot(region).querySelector("[data-primary]")).not.toBeNull();
});
+23
View File
@@ -39,6 +39,29 @@
--input: #1e2028;
--ring: #ff5c5c;
/* Shared rail chrome. Light and dark themes inherit their actual colors
through --border/--accent while keeping one geometry contract. */
--rail-header-height: 48px;
--rail-header-padding-start: 12px;
--rail-header-padding-end: 8px;
--rail-header-action-size: 28px;
--rail-header-action-glyph-size: 16px;
--rail-header-action-color: var(--muted);
--rail-header-action-hover-color: var(--text);
--rail-header-action-active-color: var(--accent);
--rail-header-action-disabled-opacity: 0.4;
--rail-header-action-gap: 2px;
--rail-header-copy-gap: 2px;
--rail-header-background: var(--bg);
--rail-header-eyebrow-size: var(--control-ui-text-xs);
--rail-header-eyebrow-letter-spacing: 0.04em;
--rail-header-title-size: var(--control-ui-text-sm);
--rail-header-title-weight: 600;
--rail-divider-color: var(--border);
--rail-divider-size: 1px;
--rail-divider-active-size: 2px;
--rail-resizer-size: 4px;
/*
* Accent - Punchy signature red
*
+201 -147
View File
@@ -2,7 +2,7 @@
.chat-workbench {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(230px, 280px);
grid-template-columns: minmax(0, 1fr);
gap: 0;
flex: 1;
min-height: 0;
@@ -15,6 +15,12 @@
grid-template-columns: minmax(0, 1fr);
}
.chat-workbench--workspace-open {
grid-template-columns:
minmax(0, 1fr) var(--rail-resizer-size)
minmax(260px, var(--chat-workspace-rail-width));
}
/* Bottom dock mirrors the terminal panel: the rail becomes a strip under the
thread instead of a column beside it. */
.chat-workbench--dock-bottom {
@@ -39,17 +45,19 @@
rules sit after the collapse/dock modifiers so the extra column wins at
equal specificity. */
.chat-workbench--tasks-open {
grid-template-columns: minmax(0, 1fr) minmax(270px, 330px);
grid-template-columns:
minmax(0, 1fr) var(--rail-resizer-size)
minmax(270px, var(--chat-tasks-rail-width));
}
.chat-workbench--tasks-open:not(.chat-workbench--workspace-collapsed):not(
.chat-workbench--dock-bottom
) {
grid-template-columns: minmax(0, 1fr) minmax(230px, 280px) minmax(270px, 330px);
.chat-workbench--workspace-open.chat-workbench--tasks-open {
grid-template-columns:
minmax(0, 1fr) var(--rail-resizer-size)
minmax(260px, var(--chat-workspace-rail-width)) var(--rail-resizer-size)
minmax(270px, var(--chat-tasks-rail-width));
}
/* Compact icon button shared by the pane header actions. Sizing only chrome
stays ghost; .btn--icon's 36px min-width/height would win otherwise. */
/* Compact icon button shared by the pane header actions. */
.chat-icon-btn {
width: 28px;
min-width: 28px;
@@ -170,6 +178,15 @@
flex: 1 1 0;
}
.chat-main__conversation-column {
display: flex;
min-width: 0;
min-height: 0;
flex: 1 1 0;
flex-direction: column;
overflow: hidden;
}
.chat-main__conversation {
position: relative;
display: flex;
@@ -196,10 +213,48 @@
.chat-main--rail-docked > openclaw-chat-session-rail {
display: block;
width: 400px;
min-width: 400px;
width: var(--chat-companion-rail-width);
min-width: 300px;
min-height: 0;
flex: 0 0 400px;
flex: 0 0 var(--chat-companion-rail-width);
}
:is(.chat-workspace-rail-resizer, .chat-tasks-rail-resizer, .chat-companion-rail-resizer) {
position: relative;
z-index: 2;
width: var(--rail-resizer-size);
min-width: var(--rail-resizer-size);
cursor: ew-resize;
background: transparent;
}
:is(.chat-workspace-rail-resizer, .chat-tasks-rail-resizer, .chat-companion-rail-resizer)::before {
position: absolute;
inset: 0 -4px;
content: "";
}
:is(.chat-workspace-rail-resizer, .chat-tasks-rail-resizer, .chat-companion-rail-resizer)::after {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: var(--rail-divider-size);
transform: translateX(-50%);
background: var(--rail-divider-color);
content: "";
transition:
background 150ms ease-out,
width 150ms ease-out;
}
:is(
.chat-workspace-rail-resizer,
.chat-tasks-rail-resizer,
.chat-companion-rail-resizer
):hover::after {
width: var(--rail-divider-active-size);
background: var(--accent);
}
openclaw-chat-sidebar-region,
@@ -242,9 +297,115 @@ openclaw-chat-sidebar-region,
flex: 0 0 auto;
align-items: center;
min-width: 0;
min-height: 42px;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--panel-strong) 90%, transparent);
}
/* Canonical rail header contract. The header owns geometry; actions are bare
glyphs with an invisible hit area so generic button chrome cannot leak in. */
.rail-header {
box-sizing: border-box;
display: flex;
height: var(--rail-header-height);
min-height: var(--rail-header-height);
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 var(--rail-header-padding-end) 0 var(--rail-header-padding-start);
border-bottom: var(--rail-divider-size) solid var(--rail-divider-color);
background: var(--rail-header-background);
}
.rail-header__copy {
display: flex;
min-width: 0;
flex: 1 1 auto;
flex-direction: column;
justify-content: center;
gap: var(--rail-header-copy-gap);
}
.rail-header__eyebrow {
overflow: hidden;
color: var(--muted);
font-size: var(--rail-header-eyebrow-size);
letter-spacing: var(--rail-header-eyebrow-letter-spacing);
line-height: 1;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.rail-header__title {
overflow: hidden;
color: var(--text);
font-size: var(--rail-header-title-size);
font-weight: var(--rail-header-title-weight);
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-header__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--rail-header-action-gap);
}
.rail-header__action {
display: inline-flex;
width: var(--rail-header-action-size);
min-width: var(--rail-header-action-size);
height: var(--rail-header-action-size);
min-height: var(--rail-header-action-size);
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: var(--radius-md);
background: transparent;
box-shadow: none;
color: var(--rail-header-action-color);
font: inherit;
opacity: 1;
transform: none;
}
.rail-header__action:hover,
.rail-header__action:focus-visible {
border: 0;
background: transparent;
box-shadow: none;
color: var(--rail-header-action-hover-color);
transform: none;
}
.rail-header__action:focus-visible {
outline: 2px solid var(--ring);
outline-offset: -3px;
}
.rail-header__action.is-active,
.rail-header__action[aria-pressed="true"] {
background: transparent;
color: var(--rail-header-action-active-color);
}
.rail-header__action:disabled,
.rail-header__action[aria-disabled="true"] {
opacity: var(--rail-header-action-disabled-opacity);
}
.rail-header__action svg,
.rail-header__action .nav-collapse-toggle__icon,
.rail-header__action .nav-collapse-toggle__icon svg {
display: block;
width: var(--rail-header-action-glyph-size);
height: var(--rail-header-action-glyph-size);
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
}
.sidebar-column__tabs {
@@ -281,6 +442,10 @@ openclaw-chat-sidebar-region,
white-space: nowrap;
}
.sidebar-column__tab:first-child::part(base) {
padding-left: 0;
}
.sidebar-column__tab[active]::part(base) {
color: var(--text);
}
@@ -298,17 +463,10 @@ openclaw-chat-sidebar-region,
.sidebar-column__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 2px;
padding-right: 5px;
}
.sidebar-column__actions .btn {
width: 30px;
height: 30px;
min-height: 30px;
padding: 6px;
.sidebar-column__single-panel {
cursor: grab;
}
.sidebar-column__body,
@@ -328,7 +486,7 @@ openclaw-chat-sidebar-region,
.sidebar-column__panel--wide {
position: absolute;
z-index: 1;
top: 42px;
top: var(--rail-header-height);
bottom: 0;
}
@@ -391,7 +549,7 @@ openclaw-chat-sidebar-region,
grid-column: 1;
grid-row: 2;
min-height: 0;
margin-top: 42px;
margin-top: var(--rail-header-height);
}
.sidebar-column--collapsed {
@@ -405,92 +563,15 @@ openclaw-chat-sidebar-region,
}
.chat-workspace-rail {
grid-column: 2;
grid-column: 3;
grid-row: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
border-left: 1px solid var(--border);
background: color-mix(in srgb, var(--panel) 84%, transparent);
}
.chat-workspace-rail__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 12px 10px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 62%, transparent);
}
.chat-workspace-rail__title {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.chat-workspace-rail__eyebrow {
color: var(--muted);
font-size: var(--control-ui-text-xs);
line-height: 1;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.chat-workspace-rail__title strong {
color: var(--text);
font-size: var(--control-ui-text-md);
line-height: 1.2;
}
.chat-workspace-rail__actions {
display: inline-flex;
align-items: center;
gap: 6px;
}
.chat-workspace-rail__refresh,
.chat-workspace-rail__dock,
.chat-workspace-rail__collapse-toggle {
width: 32px;
min-width: 32px;
height: 32px;
padding: 0;
}
/* Grab affordance for drag-to-dock. */
.chat-workspace-rail__grip {
cursor: grab;
user-select: none;
-webkit-user-select: none;
touch-action: none;
}
.chat-workspace-rail__grip:active {
cursor: grabbing;
}
/* Overrides the shared .nav-collapse-toggle chrome (border, elevated
background, inset highlight): rail header buttons only show chrome on
hover. */
.chat-workspace-rail__collapse-toggle {
flex: 0 0 auto;
border-color: transparent;
background: transparent;
box-shadow: none;
}
.chat-workspace-rail__collapse-toggle:hover {
border-color: transparent;
background: var(--bg-hover);
transform: none;
}
.chat-workspace-rail__refresh svg,
.chat-workspace-rail__dock svg,
.chat-workspace-rail__collapse-toggle svg,
.chat-workspace-rail__file-icon svg {
width: 15px;
height: 15px;
@@ -799,58 +880,31 @@ openclaw-chat-sidebar-region,
flex-direction: column;
min-width: 0;
min-height: 0;
border-left: 1px solid var(--border);
background: color-mix(in srgb, var(--panel) 84%, transparent);
}
.chat-tasks-rail__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 12px 10px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 62%, transparent);
.chat-workspace-rail-resizer {
grid-column: 2;
grid-row: 1;
}
.chat-tasks-rail__title {
.chat-workbench--workspace-open.chat-workbench--tasks-open .chat-tasks-rail-resizer {
grid-column: 4;
grid-row: 1;
}
.chat-workbench--tasks-open:not(.chat-workbench--workspace-open) .chat-tasks-rail-resizer {
grid-column: 2;
grid-row: 1;
}
.chat-pane-primary-column {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.chat-tasks-rail__eyebrow {
color: var(--muted);
font-size: var(--control-ui-text-xs);
line-height: 1.1;
min-height: 0;
flex: 1 1 0;
flex-direction: column;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.chat-tasks-rail__title strong {
overflow: hidden;
font-size: var(--control-ui-text-sm);
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-tasks-rail__actions {
display: inline-flex;
align-items: center;
gap: 4px;
}
.chat-tasks-rail__refresh svg,
.chat-tasks-rail__collapse-toggle svg {
width: 15px;
height: 15px;
fill: none;
stroke: currentColor;
stroke-width: 1.7px;
}
.chat-tasks-rail__state {
+10 -5
View File
@@ -747,7 +747,8 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-
.shell:not(.shell--nav-collapsed):not(.shell--mobile-nav)
.chat-split-view__column:first-child
> .chat-split-view__cell:first-child
.chat-pane__header {
:is(.chat-main__conversation-column, .chat-pane-primary-column)
> .chat-pane__header {
padding-left: 88px;
}
@@ -755,7 +756,8 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-
.shell--nav-collapsed:not(.shell--mobile-nav)
.chat-split-view__column:first-child
> .chat-split-view__cell:first-child
.chat-pane__header {
:is(.chat-main__conversation-column, .chat-pane-primary-column)
> .chat-pane__header {
padding-left: 124px;
}
@@ -790,7 +792,8 @@ html.openclaw-native-macos
.shell--nav-collapsed:not(.shell--mobile-nav)
.chat-split-view__column:first-child
> .chat-split-view__cell:first-child
.chat-pane__header {
:is(.chat-main__conversation-column, .chat-pane-primary-column)
> .chat-pane__header {
padding-left: 90px;
}
@@ -804,7 +807,8 @@ html.openclaw-native-nav
.shell--nav-collapsed:not(.shell--mobile-nav)
.chat-split-view__column:first-child
> .chat-split-view__cell:first-child
.chat-pane__header {
:is(.chat-main__conversation-column, .chat-pane-primary-column)
> .chat-pane__header {
padding-left: 204px;
}
@@ -820,6 +824,7 @@ html.openclaw-native-web-chrome
.shell--nav-collapsed:not(.shell--mobile-nav)
.chat-split-view__column:first-child
> .chat-split-view__cell:first-child
.chat-pane__header {
:is(.chat-main__conversation-column, .chat-pane-primary-column)
> .chat-pane__header {
padding-left: 246px;
}
+1 -7
View File
@@ -1620,17 +1620,10 @@ openclaw-chat-session-rail {
.chat-session-rail__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
flex: 0 0 auto;
padding: 12px 10px 10px 14px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
}
.chat-session-rail__header-copy {
display: grid;
gap: 5px;
min-width: 0;
flex: 1 1 auto;
}
@@ -2023,6 +2016,7 @@ openclaw-chat-session-rail {
position: static;
width: 100%;
height: 100%;
border-left-width: 0;
}
@media (max-width: 768px) {
+36 -37
View File
@@ -19,14 +19,12 @@ openclaw-custodian-panel {
right: calc(var(--oc-terminal-reserve-right, 0px) + var(--oc-browser-reserve-right, 0px));
bottom: calc(var(--oc-terminal-reserve-bottom, 0px) + var(--oc-browser-reserve-bottom, 0px));
left: var(--shell-nav-width, 0);
border-top: 1px solid var(--border);
}
.cp--right {
top: var(--shell-topbar-height, 0);
right: calc(var(--oc-terminal-reserve-right, 0px) + var(--oc-browser-reserve-right, 0px));
bottom: calc(var(--oc-terminal-reserve-bottom, 0px) + var(--oc-browser-reserve-bottom, 0px));
border-left: 1px solid var(--border);
}
.cp-resizer {
@@ -35,36 +33,56 @@ openclaw-custodian-panel {
background: transparent;
}
.cp-resizer:hover {
background: var(--accent);
opacity: 0.5;
.cp-resizer::after {
position: absolute;
content: "";
background: var(--rail-divider-color);
transition:
background 150ms ease-out,
width 150ms ease-out,
height 150ms ease-out;
}
.cp-resizer--bottom {
top: 0;
right: 0;
left: 0;
height: 5px;
height: var(--rail-resizer-size);
cursor: ns-resize;
}
.cp-resizer--bottom::after {
top: 50%;
right: 0;
left: 0;
height: var(--rail-divider-size);
transform: translateY(-50%);
}
.cp-resizer--right {
top: 0;
bottom: 0;
left: 0;
width: 5px;
width: var(--rail-resizer-size);
cursor: ew-resize;
}
.cp-header {
display: flex;
min-height: 38px;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 6px 0 8px;
border-bottom: 1px solid var(--border);
.cp-resizer--right::after {
top: 0;
bottom: 0;
left: 50%;
width: var(--rail-divider-size);
transform: translateX(-50%);
}
.cp-resizer--bottom:hover::after {
height: var(--rail-divider-active-size);
background: var(--accent);
}
.cp-resizer--right:hover::after {
width: var(--rail-divider-active-size);
background: var(--accent);
}
.cp-title,
@@ -81,30 +99,11 @@ openclaw-custodian-panel {
}
.cp-actions {
gap: 2px;
gap: var(--rail-header-action-gap);
}
.cp-icon {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--muted);
}
.cp-icon:hover {
background: var(--bg-hover);
color: var(--text);
}
.cp-icon svg {
width: 15px;
height: 15px;
flex: 0 0 auto;
}
.cp > openclaw-custodian-surface {