diff --git a/ui/src/app/settings.node.test.ts b/ui/src/app/settings.node.test.ts index c36fe556afe0..233a6560047f 100644 --- a/ui/src/app/settings.node.test.ts +++ b/ui/src/app/settings.node.test.ts @@ -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:", diff --git a/ui/src/components/browser/browser-panel-render.ts b/ui/src/components/browser/browser-panel-render.ts index 22763fb4ef45..3b0ab4594b3a 100644 --- a/ui/src/components/browser/browser-panel-render.ts +++ b/ui/src/components/browser/browser-panel-render.ts @@ -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``; -const DOCK_BOTTOM_GLYPH = svg``; -const DOCK_RIGHT_GLYPH = svg``; const BACK_GLYPH = svg``; const FORWARD_GLYPH = svg``; const RELOAD_GLYPH = svg``; -const EXTERNAL_GLYPH = svg``; const PENCIL_GLYPH = svg``; const INSPECT_GLYPH = svg``; @@ -34,27 +32,27 @@ function renderHeaderActions( ) { const activeUrl = controller.view?.metrics?.url || controller.view?.url || controller.urlDraft; return html` -
+
`; @@ -327,7 +325,7 @@ export function renderBrowserPanelChrome( return html`
${resizer} -
+
${renderTabStrip(controller)} ${renderHeaderActions(controller, dock, onDockChange, onClose)}
diff --git a/ui/src/components/browser/browser-panel.styles.ts b/ui/src/components/browser/browser-panel.styles.ts index db4badbfa7d9..cefb8b576ad7 100644 --- a/ui/src/components/browser/browser-panel.styles.ts +++ b/ui/src/components/browser/browser-panel.styles.ts @@ -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; diff --git a/ui/src/components/custodian/custodian-panel.ts b/ui/src/components/custodian/custodian-panel.ts index 02076252c335..76d751d16d12 100644 --- a/ui/src/components/custodian/custodian-panel.ts +++ b/ui/src/components/custodian/custodian-panel.ts @@ -105,17 +105,17 @@ export class OpenClawCustodianPanel extends OpenClawLightDomElement { return html`
${this.dockLayout.renderResizer("cp", t("custodian.panel.resize"))} -
+
- ${t("custodian.panel.title")} + ${t("custodian.panel.title")}
-
+
diff --git a/ui/src/components/dock-layout-controller.ts b/ui/src/components/dock-layout-controller.ts index f336fa2dd4a9..48d727cf6da8 100644 --- a/ui/src/components/dock-layout-controller.ts +++ b/ui/src/components/dock-layout-controller.ts @@ -15,6 +15,8 @@ type DockLayoutControllerOptions = { reservationPrefix: string; isAvailable: () => boolean; isFullscreen?: () => boolean; + maxWidth?: () => number; + reserveViewport?: boolean; onResize?: () => void; }; @@ -28,7 +30,7 @@ export class DockLayoutController 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 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 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 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 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 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; } `; diff --git a/ui/src/components/dock-panel-layout.test.ts b/ui/src/components/dock-panel-layout.test.ts index 996c015fb35b..ada813db8cad 100644 --- a/ui/src/components/dock-panel-layout.test.ts +++ b/ui/src/components/dock-panel-layout.test.ts @@ -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); + }); +}); diff --git a/ui/src/components/form-controls.browser.test.ts b/ui/src/components/form-controls.browser.test.ts index 8fdc2658c3f3..205edb64d5a5 100644 --- a/ui/src/components/form-controls.browser.test.ts +++ b/ui/src/components/form-controls.browser.test.ts @@ -380,7 +380,6 @@ describeBrowserLayout("app chrome interaction styles", () => { Agent Settings workspace/file.ts - Dock here 3 files ⌘K