mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
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:
committed by
GitHub
parent
4137c58c97
commit
f73b436f05
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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% {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user