From fb26903d6ca415535dd4a89586f65918aa69b9d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Jul 2026 17:45:29 -0700 Subject: [PATCH] feat: add flexible multi-sidebar chat layout (#113712) * feat(ui): add persisted sidebar layout model * feat(ui): render flexible chat sidebar columns * test(ui): align board sidebar fixture type * fix(ui): preserve sidebar panels across responsive layout * fix(ui): satisfy sidebar CI ownership and performance * test(ui): derive discussion helpers from panel config * test(ui): mount session rail through its registry * fix(ui): preserve sidebar state across projections * style(ui): format sidebar state fixes * fix(ui): satisfy sidebar lint constraints * refactor(ui): break sidebar layout import cycle * fix(ui): stabilize sidebar panel rendering * fix(ui): keep the narrow sidebar grid off for an empty layout The two-row narrow grid reserved a panel row even with no sidebar panel open, halving the primary surface height on every default mobile chat pane. * fix(ui): lazy-load chat sidebar region * style(ui): format rebased chat state page * chore(ui): raise sidebar startup baseline * fix(ui): preserve sidebar move and resize state * chore(ui): align sidebar startup baseline * chore(ui): refresh sidebar startup baseline * test(ui): register discussion element in isolated test * fix(ui): persist the dragged panel as the collapsed active panel Drag moves activated the panel in its destination column but left the separate persisted collapsed-mode selection stale, so the narrow layout foregrounded the wrong panel after a move and the stale choice survived reload. * fix(ui): preserve resolved canvas URL in detail panel --- .../control-ui-startup-budget-baseline.json | 4 +- ui/src/app/settings.node.test.ts | 44 +- ui/src/app/settings.ts | 31 +- ui/src/components/resizable-divider.ts | 31 +- ui/src/i18n/locales/en.ts | 8 + ui/src/lib/board/chat-layout.ts | 16 - ui/src/lib/sessions/session-key.ts | 7 + .../pages/chat/board-session-surface.test.ts | 26 +- ui/src/pages/chat/board-session-surface.ts | 13 +- ui/src/pages/chat/chat-controls.test.ts | 1 - ui/src/pages/chat/chat-pane-base.ts | 9 + ui/src/pages/chat/chat-pane-board.test.ts | 120 ++++-- ui/src/pages/chat/chat-pane-board.ts | 148 ++++++- ui/src/pages/chat/chat-pane-context.ts | 27 +- ui/src/pages/chat/chat-pane-deps.ts | 25 +- ui/src/pages/chat/chat-pane-header-render.ts | 1 + ui/src/pages/chat/chat-pane-header.ts | 94 +++-- ui/src/pages/chat/chat-pane-history.test.ts | 2 +- ui/src/pages/chat/chat-pane-lifecycle.ts | 1 + ui/src/pages/chat/chat-pane-render.ts | 177 +++++--- ui/src/pages/chat/chat-pane-shared.ts | 4 - .../chat/chat-pane-sidebar-layout.test.ts | 208 +++++++++ ui/src/pages/chat/chat-pane-sidebar-layout.ts | 210 ++++++++++ .../pages/chat/chat-pane.message-cut.test.ts | 2 +- .../chat/chat-pane.session-discussion.test.ts | 171 +++++--- ui/src/pages/chat/chat-pane.test-support.ts | 11 +- ui/src/pages/chat/chat-pane.test.ts | 7 +- .../chat/chat-responsive.browser.test.ts | 35 +- ui/src/pages/chat/chat-send.test.ts | 1 - ui/src/pages/chat/chat-session-rail.test.ts | 2 +- ui/src/pages/chat/chat-state-host.ts | 9 +- ui/src/pages/chat/chat-state-page.ts | 83 +++- ui/src/pages/chat/chat-state-route.ts | 15 +- ui/src/pages/chat/chat-state.test.ts | 11 + ui/src/pages/chat/chat-view.test.ts | 85 +--- ui/src/pages/chat/chat-view.ts | 59 +-- .../chat/components/chat-resizable-divider.ts | 8 + .../components/chat-sidebar-region-types.ts | 12 + .../components/chat-sidebar-region.runtime.ts | 395 ++++++++++++++++++ .../components/chat-sidebar-region.test.ts | 328 +++++++++++++++ ui/src/pages/chat/components/chat-sidebar.ts | 249 +++++------ .../session-discussion-panel.test.ts | 39 +- .../components/session-discussion-panel.ts | 18 +- ui/src/pages/chat/sidebar-layout-normalize.ts | 81 ++++ .../chat/sidebar-layout-persistence.test.ts | 76 ++++ .../pages/chat/sidebar-layout-persistence.ts | 68 +++ ui/src/pages/chat/sidebar-layout-types.ts | 11 + ui/src/pages/chat/sidebar-layout.test.ts | 186 +++++++++ ui/src/pages/chat/sidebar-layout.ts | 268 ++++++++++++ ui/src/pages/connection/view.render.test.ts | 1 - ui/src/styles/chat/board.css | 15 - ui/src/styles/chat/sidebar.css | 229 +++++++--- ui/src/styles/layout.mobile.css | 2 - 53 files changed, 2978 insertions(+), 706 deletions(-) delete mode 100644 ui/src/lib/board/chat-layout.ts create mode 100644 ui/src/pages/chat/chat-pane-sidebar-layout.test.ts create mode 100644 ui/src/pages/chat/chat-pane-sidebar-layout.ts create mode 100644 ui/src/pages/chat/components/chat-sidebar-region-types.ts create mode 100644 ui/src/pages/chat/components/chat-sidebar-region.runtime.ts create mode 100644 ui/src/pages/chat/components/chat-sidebar-region.test.ts create mode 100644 ui/src/pages/chat/sidebar-layout-normalize.ts create mode 100644 ui/src/pages/chat/sidebar-layout-persistence.test.ts create mode 100644 ui/src/pages/chat/sidebar-layout-persistence.ts create mode 100644 ui/src/pages/chat/sidebar-layout-types.ts create mode 100644 ui/src/pages/chat/sidebar-layout.test.ts create mode 100644 ui/src/pages/chat/sidebar-layout.ts diff --git a/config/control-ui-startup-budget-baseline.json b/config/control-ui-startup-budget-baseline.json index 0dc29114d849..6bd246c18c08 100644 --- a/config/control-ui-startup-budget-baseline.json +++ b/config/control-ui-startup-budget-baseline.json @@ -1,5 +1,5 @@ { - "startupJsGzipBytes": 319655, - "reason": "canvas capability renewal lease", + "startupJsGzipBytes": 319835, + "reason": "flexible multi-sidebar chat layout", "updatedAt": "2026-07-25" } diff --git a/ui/src/app/settings.node.test.ts b/ui/src/app/settings.node.test.ts index 84acce8d23f9..48e3cc286417 100644 --- a/ui/src/app/settings.node.test.ts +++ b/ui/src/app/settings.node.test.ts @@ -1,5 +1,6 @@ // @vitest-environment node import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { openSlot } from "../pages/chat/sidebar-layout.ts"; import { createImportedCustomThemeFixture } from "../test-helpers/custom-theme.ts"; import { createStorageMock } from "../test-helpers/storage.ts"; import { @@ -59,7 +60,6 @@ function makeSettings(gatewayUrl: string, overrides: Partial = {}): themeMode: "system", chatShowThinking: true, chatShowToolCalls: true, - splitRatio: 0.6, navCollapsed: false, navWidth: 258, sidebarEntries: [], @@ -261,7 +261,6 @@ describe("loadSettings default gateway URL derivation", () => { themeMode: "system", chatShowThinking: true, chatShowToolCalls: true, - splitRatio: 0.6, navCollapsed: false, navWidth: 258, sidebarEntries: [], @@ -291,7 +290,6 @@ describe("loadSettings default gateway URL derivation", () => { themeMode: "system", chatShowThinking: true, chatShowToolCalls: true, - splitRatio: 0.6, navCollapsed: false, navWidth: 258, sidebarEntries: [], @@ -306,7 +304,6 @@ describe("loadSettings default gateway URL derivation", () => { themeMode: "system", chatShowThinking: true, chatShowToolCalls: true, - splitRatio: 0.6, navCollapsed: false, navWidth: 258, sidebarEntries: [], @@ -334,7 +331,6 @@ describe("loadSettings default gateway URL derivation", () => { themeMode: "system", chatShowThinking: true, chatShowToolCalls: true, - splitRatio: 0.6, navCollapsed: false, navWidth: 258, sidebarEntries: [], @@ -351,7 +347,6 @@ describe("loadSettings default gateway URL derivation", () => { chatShowThinking: true, chatShowToolCalls: true, chatPersistCommentary: true, - splitRatio: 0.6, navCollapsed: false, navWidth: 258, sidebarEntries: [], @@ -383,7 +378,6 @@ describe("loadSettings default gateway URL derivation", () => { themeMode: "system", chatShowThinking: true, chatShowToolCalls: true, - splitRatio: 0.6, navCollapsed: false, navWidth: 258, sidebarEntries: ["route:tasks", "route:cron"], @@ -443,7 +437,6 @@ describe("loadSettings default gateway URL derivation", () => { themeMode: "system", chatShowThinking: true, chatShowToolCalls: true, - splitRatio: 0.6, navCollapsed: false, navWidth: 258, sidebarEntries: [], @@ -756,7 +749,6 @@ describe("loadSettings default gateway URL derivation", () => { themeMode: "system", chatShowThinking: true, chatShowToolCalls: true, - splitRatio: 0.6, navCollapsed: false, navWidth: 258, sidebarEntries: [], @@ -770,7 +762,6 @@ describe("loadSettings default gateway URL derivation", () => { themeMode: "system", chatShowThinking: true, chatShowToolCalls: true, - splitRatio: 0.6, navCollapsed: false, navWidth: 258, sidebarEntries: [], @@ -797,7 +788,6 @@ describe("loadSettings default gateway URL derivation", () => { themeMode: "light", chatShowThinking: true, chatShowToolCalls: true, - splitRatio: 0.6, navCollapsed: false, navWidth: 320, sidebarEntries: [], @@ -875,6 +865,34 @@ describe("loadSettings default gateway URL derivation", () => { expect(loadSettings().boardSessionViews).toEqual({}); }); + it("persists normalized sidebar layouts per session", () => { + setTestLocation({ protocol: "https:", host: "gateway.example:8443", pathname: "/" }); + const settings = loadSettings(); + const sidebarSessionLayouts = { + "agent:main:main": openSlot({ columns: [] }, "discussion"), + }; + + saveSettings({ ...settings, sidebarSessionLayouts }); + + expect(loadSettings().sidebarSessionLayouts).toEqual(sidebarSessionLayouts); + }); + + it("normalizes corrupt stored sidebar layouts to empty columns", () => { + setTestLocation({ protocol: "https:", host: "gateway.example:8443", pathname: "/" }); + const gwUrl = expectedGatewayUrl(""); + localStorage.setItem( + `openclaw.control.settings.v1:${gwUrl}`, + JSON.stringify({ + gatewayUrl: gwUrl, + sidebarSessionLayouts: { "agent:main:main": { columns: "invalid" } }, + }), + ); + + expect(loadSettings().sidebarSessionLayouts).toEqual({ + "agent:main:main": { columns: [] }, + }); + }); + it("omits an invalid stored chat split layout", () => { setTestLocation({ protocol: "https:", @@ -908,7 +926,6 @@ describe("loadSettings default gateway URL derivation", () => { themeMode: "system", chatShowThinking: true, chatShowToolCalls: true, - splitRatio: 0.6, navCollapsed: false, navWidth: 258, sidebarEntries: [], @@ -937,7 +954,6 @@ describe("loadSettings default gateway URL derivation", () => { themeMode: "dark", chatShowThinking: true, chatShowToolCalls: true, - splitRatio: 0.6, navCollapsed: false, navWidth: 258, sidebarEntries: [], @@ -980,7 +996,6 @@ describe("loadSettings default gateway URL derivation", () => { themeMode: "system", chatShowThinking: true, chatShowToolCalls: true, - splitRatio: 0.6, navCollapsed: false, navWidth: 258, sidebarEntries: [], @@ -1022,7 +1037,6 @@ describe("loadSettings default gateway URL derivation", () => { themeMode: "system", chatShowThinking: true, chatShowToolCalls: true, - splitRatio: 0.6, navCollapsed: false, navWidth: 258, sidebarEntries: [], diff --git a/ui/src/app/settings.ts b/ui/src/app/settings.ts index c388461165a7..31b86f5f5883 100644 --- a/ui/src/app/settings.ts +++ b/ui/src/app/settings.ts @@ -40,6 +40,12 @@ import { isSupportedLocale } from "../i18n/index.ts"; import { normalizeBoardSessionViews, type BoardSessionViews } from "../lib/board/settings.ts"; import { normalizeOptionalString } from "../lib/string-coerce.ts"; import { getSafeLocalStorage, getSafeSessionStorage } from "../local-storage.ts"; +import { + normalizeSidebarSessionActivePanels, + normalizeSidebarSessionLayouts, + type SidebarSessionActivePanels, + type SidebarSessionLayouts, +} from "../pages/chat/sidebar-layout-persistence.ts"; import { normalizeChatSplitLayout, type ChatSplitLayout } from "../pages/chat/split-layout.ts"; import { resolveControlUiBasePath } from "./browser.ts"; import { parseImportedCustomTheme, type ImportedCustomTheme } from "./custom-theme.ts"; @@ -179,10 +185,11 @@ export type UiSettings = { composerHoldToRecord?: boolean; // Camera intent is device-local, not per-agent or synced through config ui.prefs. talkCameraAutoEnable?: boolean; - splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6) chatSplitLayout?: ChatSplitLayout; chatWorkspaceDock?: ChatWorkspaceDock; // Session workspace rail dock edge (default "right") boardSessionViews?: BoardSessionViews; // Last face and active dashboard tab per session + sidebarSessionLayouts?: SidebarSessionLayouts; // Sidebar columns and widths per session + sidebarSessionActivePanels?: SidebarSessionActivePanels; // Collapsed active panel per session navCollapsed: boolean; // Collapsible sidebar state navWidth: number; // Sidebar width when expanded (240–400px) sidebarEntries: string[]; // Ordered routes, Workboard boards, and pinned sessions below Home @@ -417,7 +424,6 @@ export function loadSettings(): UiSettings { chatPersistCommentary: true, chatSendShortcut: "enter", catalogOpenTarget: "viewer", - splitRatio: 0.6, navCollapsed: false, navWidth: NAV_WIDTH_DEFAULT, sidebarEntries: [...DEFAULT_SIDEBAR_ENTRIES], @@ -499,15 +505,13 @@ export function loadSettings(): UiSettings { : defaults.composerHoldToRecord, talkCameraAutoEnable: typeof parsed.talkCameraAutoEnable === "boolean" ? parsed.talkCameraAutoEnable : undefined, - splitRatio: - typeof parsed.splitRatio === "number" && - parsed.splitRatio >= 0.4 && - parsed.splitRatio <= 0.7 - ? parsed.splitRatio - : defaults.splitRatio, chatSplitLayout: normalizeChatSplitLayout(parsed.chatSplitLayout), chatWorkspaceDock: normalizeChatWorkspaceDock(parsed.chatWorkspaceDock), boardSessionViews: normalizeBoardSessionViews(parsed.boardSessionViews), + sidebarSessionLayouts: normalizeSidebarSessionLayouts(parsed.sidebarSessionLayouts), + sidebarSessionActivePanels: normalizeSidebarSessionActivePanels( + parsed.sidebarSessionActivePanels, + ), navCollapsed: typeof parsed.navCollapsed === "boolean" ? parsed.navCollapsed : defaults.navCollapsed, navWidth: @@ -641,13 +645,22 @@ function persistSettings(next: UiSettings, options: { selectGateway?: boolean } ...(typeof next.talkCameraAutoEnable === "boolean" ? { talkCameraAutoEnable: next.talkCameraAutoEnable } : {}), - splitRatio: next.splitRatio, ...(next.chatSplitLayout ? { chatSplitLayout: next.chatSplitLayout } : {}), // Right dock is the default; only the opt-in bottom dock persists. ...(next.chatWorkspaceDock === "bottom" ? { chatWorkspaceDock: "bottom" as const } : {}), ...(next.boardSessionViews && Object.keys(next.boardSessionViews).length > 0 ? { boardSessionViews: normalizeBoardSessionViews(next.boardSessionViews) } : {}), + ...(next.sidebarSessionLayouts && Object.keys(next.sidebarSessionLayouts).length > 0 + ? { sidebarSessionLayouts: normalizeSidebarSessionLayouts(next.sidebarSessionLayouts) } + : {}), + ...(next.sidebarSessionActivePanels && Object.keys(next.sidebarSessionActivePanels).length > 0 + ? { + sidebarSessionActivePanels: normalizeSidebarSessionActivePanels( + next.sidebarSessionActivePanels, + ), + } + : {}), navCollapsed: next.navCollapsed, navWidth: next.navWidth, sidebarEntries: next.sidebarEntries, diff --git a/ui/src/components/resizable-divider.ts b/ui/src/components/resizable-divider.ts index ef47e18c7b47..64f55fe8adf5 100644 --- a/ui/src/components/resizable-divider.ts +++ b/ui/src/components/resizable-divider.ts @@ -13,6 +13,8 @@ class ResizableDivider extends OpenClawLitElement { @property({ type: Number }) maxRatio = 0.7; @property({ type: String }) label = "Resize split view"; @property({ type: String, reflect: true }) orientation: "vertical" | "horizontal" = "vertical"; + @property({ attribute: false }) measureRatio?: () => number; + @property({ attribute: false }) measureSize?: () => number; private isDragging = false; private startPosition = 0; @@ -129,7 +131,7 @@ class ResizableDivider extends OpenClawLitElement { } this.isDragging = true; this.startPosition = this.orientation === "horizontal" ? e.clientY : e.clientX; - this.startRatio = this.splitRatio; + this.startRatio = this.currentRatio(); this.classList.add("dragging"); this.focus(); this.capturePointer(e.pointerId); @@ -158,10 +160,19 @@ class ResizableDivider extends OpenClawLitElement { const previousBounds = this.previousElementSibling?.getBoundingClientRect(); const nextBounds = this.nextElementSibling?.getBoundingClientRect(); const containerBounds = container.getBoundingClientRect(); - const containerSize = + const measuredSize = this.measureSize?.() ?? 0; + const siblingSize = this.orientation === "horizontal" - ? (previousBounds?.height ?? 0) + (nextBounds?.height ?? 0) || containerBounds.height - : (previousBounds?.width ?? 0) + (nextBounds?.width ?? 0) || containerBounds.width; + ? (previousBounds?.height ?? 0) + (nextBounds?.height ?? 0) + : (previousBounds?.width ?? 0) + (nextBounds?.width ?? 0); + const containerSize = + measuredSize > 0 + ? measuredSize + : siblingSize || + (this.orientation === "horizontal" ? containerBounds.height : containerBounds.width); + if (containerSize <= 0) { + return; + } const position = this.orientation === "horizontal" ? e.clientY : e.clientX; const deltaRatio = (position - this.startPosition) / containerSize; @@ -174,14 +185,15 @@ class ResizableDivider extends OpenClawLitElement { private handleKeyDown = (e: KeyboardEvent) => { const step = e.shiftKey ? 0.05 : 0.02; + const currentRatio = this.currentRatio(); let nextRatio: number | null = null; const decreaseKey = this.orientation === "horizontal" ? "ArrowUp" : "ArrowLeft"; const increaseKey = this.orientation === "horizontal" ? "ArrowDown" : "ArrowRight"; if (e.key === decreaseKey) { - nextRatio = this.splitRatio - step; + nextRatio = currentRatio - step; } else if (e.key === increaseKey) { - nextRatio = this.splitRatio + step; + nextRatio = currentRatio + step; } else if (e.key === "Home") { nextRatio = this.minRatio; } else if (e.key === "End") { @@ -224,6 +236,13 @@ class ResizableDivider extends OpenClawLitElement { return Math.max(this.minRatio, Math.min(this.maxRatio, value)); } + private currentRatio() { + const measuredRatio = this.measureRatio?.(); + return measuredRatio !== undefined && Number.isFinite(measuredRatio) + ? this.clampRatio(measuredRatio) + : this.splitRatio; + } + private toAriaValue(value: number) { return Math.round(value * 100); } diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index c23848cc20e0..745bc8adcc1e 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4213,6 +4213,14 @@ export const en: TranslationMap = { noPreviewableMarkdown: "No previewable markdown content.", noContent: "No content available", }, + sidebarColumns: { + chat: "Chat", + discussion: "Discussion", + detail: "Details", + close: "Close {panel}", + drag: "Drag {panel}", + resize: "Resize {panel}", + }, thread: { search: "Search messages", searchPlaceholder: "Search messages...", diff --git a/ui/src/lib/board/chat-layout.ts b/ui/src/lib/board/chat-layout.ts deleted file mode 100644 index 33171b061ab9..000000000000 --- a/ui/src/lib/board/chat-layout.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { BoardFace } from "./settings.ts"; -import type { BoardTab } from "./types.ts"; - -export function resolveBoardChatLayoutWidth(params: { - paneWidth: number; - hasBoard: boolean; - face: BoardFace; - dock: BoardTab["chatDock"]; - dockWidth: number; -}): number { - return params.hasBoard && - params.face === "dashboard" && - (params.dock === "left" || params.dock === "right") - ? Math.min(params.paneWidth, params.dockWidth) - : params.paneWidth; -} diff --git a/ui/src/lib/sessions/session-key.ts b/ui/src/lib/sessions/session-key.ts index 07b588ebc2ec..fe1e80b51cf3 100644 --- a/ui/src/lib/sessions/session-key.ts +++ b/ui/src/lib/sessions/session-key.ts @@ -230,6 +230,13 @@ function normalizeUiSessionEventKey( return aliases.has(normalized) ? normalizeLowercaseStringOrEmpty(canonicalMain) : normalized; } +export function canonicalUiSessionKeyForPersistence( + host: Pick, + sessionKey: string | undefined | null, +): string { + return normalizeUiSessionEventKey(host, sessionKey) ?? ""; +} + export function areUiSessionKeysEquivalentForHost( host: Pick, left: string | undefined | null, diff --git a/ui/src/pages/chat/board-session-surface.test.ts b/ui/src/pages/chat/board-session-surface.test.ts index bbed6e290085..3559bf31a9ed 100644 --- a/ui/src/pages/chat/board-session-surface.test.ts +++ b/ui/src/pages/chat/board-session-surface.test.ts @@ -39,7 +39,7 @@ describe("board session shell", () => { activeTabId: "main", dock: "right" as const, reopenDock: "right" as const, - dockSize: { height: 300, width: 420 }, + dockSize: { height: 300 }, chat: html`
chat
`, divider: html`
`, canMutate: true, @@ -105,7 +105,7 @@ describe("board session shell", () => { expect(onChange).toHaveBeenCalledWith("dashboard"); }); - it.each(["left", "right", "bottom"] as const)("lays chat out on the %s edge", (dock) => { + it.each(["left", "right", "bottom"] as const)("lays out the %s dock", (dock) => { const container = createContainer(); const provider = boardProviderForSession("agent:main:main"); render( @@ -114,7 +114,7 @@ describe("board session shell", () => { activeTabId: "main", dock, reopenDock: "right", - dockSize: { height: 300, width: 420 }, + dockSize: { height: 300 }, chat: html`
chat
`, divider: html`
`, canMutate: true, @@ -131,8 +131,8 @@ describe("board session shell", () => { ); expect(container.querySelector(`.board-session-surface--dock-${dock}`)).not.toBeNull(); - expect(container.querySelector("[data-test-divider]")).not.toBeNull(); - expect(container.querySelector("[data-test-chat]")).not.toBeNull(); + expect(container.querySelector("[data-test-divider]") !== null).toBe(dock === "bottom"); + expect(container.querySelector("[data-test-chat]") !== null).toBe(dock === "bottom"); expect(container.querySelector("openclaw-board-view")).not.toBeNull(); }); @@ -146,7 +146,7 @@ describe("board session shell", () => { activeTabId: "main", dock: "hidden", reopenDock: "left", - dockSize: { height: 300, width: 420 }, + dockSize: { height: 300 }, chat: html`
chat
`, divider: html`
`, canMutate: true, @@ -162,21 +162,21 @@ describe("board session shell", () => { container, ); - expect(container.querySelector("[data-test-chat]")).not.toBeNull(); + expect(container.querySelector("[data-test-chat]")).toBeNull(); expect(container.querySelector(".board-session-surface--dock-hidden")).not.toBeNull(); const reopen = container.querySelector(".board-session-surface__reopen"); reopen?.click(); expect(onDockChange).toHaveBeenCalledWith("left"); }); - it("preserves board and chat nodes while changing dock state", () => { + it("preserves the board while the bottom chat mounts only for that dock", () => { const container = createContainer(); const provider = boardProviderForSession("agent:main:main"); const props = { snapshot: provider.snapshot$.value, activeTabId: "main", reopenDock: "left" as const, - dockSize: { height: 300, width: 420 }, + dockSize: { height: 300 }, chat: html`
chat
`, divider: html`
`, canMutate: true, @@ -192,18 +192,18 @@ describe("board session shell", () => { render(renderBoardSessionSurface({ ...props, dock: "right" }), container); const board = container.querySelector("openclaw-board-view"); - const chat = container.querySelector("[data-test-chat]"); + expect(container.querySelector("[data-test-chat]")).toBeNull(); render(renderBoardSessionSurface({ ...props, dock: "left" }), container); expect(container.querySelector("openclaw-board-view")).toBe(board); - expect(container.querySelector("[data-test-chat]")).toBe(chat); + expect(container.querySelector("[data-test-chat]")).toBeNull(); render(renderBoardSessionSurface({ ...props, dock: "bottom" }), container); expect(container.querySelector("openclaw-board-view")).toBe(board); - expect(container.querySelector("[data-test-chat]")).toBe(chat); + expect(container.querySelector("[data-test-chat]")).not.toBeNull(); render(renderBoardSessionSurface({ ...props, dock: "hidden" }), container); expect(container.querySelector("openclaw-board-view")).toBe(board); - expect(container.querySelector("[data-test-chat]")).toBe(chat); + expect(container.querySelector("[data-test-chat]")).toBeNull(); }); }); diff --git a/ui/src/pages/chat/board-session-surface.ts b/ui/src/pages/chat/board-session-surface.ts index 5d1e6172a34d..1bac543e4188 100644 --- a/ui/src/pages/chat/board-session-surface.ts +++ b/ui/src/pages/chat/board-session-surface.ts @@ -15,7 +15,6 @@ import type { export type BoardChatDockSize = { height: number; - width: number; }; export type WorkboardCardChipProps = { @@ -176,17 +175,17 @@ function renderBoardView(props: BoardSessionSurfaceProps) { `; } -function renderChatDock(props: BoardSessionSurfaceProps, dock: BoardVisibleChatDock) { - const style = - dock === "bottom" ? `height: ${props.dockSize.height}px` : `width: ${props.dockSize.width}px`; - return html`
${props.chat}
`; +function renderChatDock(props: BoardSessionSurfaceProps) { + return html`
+ ${props.chat} +
`; } export function renderBoardSessionSurface(props: BoardSessionSurfaceProps) { - const layoutDock = props.dock === "hidden" ? props.reopenDock : props.dock; return html`
- ${renderBoardView(props)} ${props.divider} ${renderChatDock(props, layoutDock)} + ${renderBoardView(props)} + ${props.dock === "bottom" ? html`${props.divider}${renderChatDock(props)}` : nothing} diff --git a/ui/src/pages/chat/chat-pane-history.test.ts b/ui/src/pages/chat/chat-pane-history.test.ts index 81dd84e145bb..e6e0ec83c806 100644 --- a/ui/src/pages/chat/chat-pane-history.test.ts +++ b/ui/src/pages/chat/chat-pane-history.test.ts @@ -82,7 +82,7 @@ function createTestChatPane(params: { client: GatewayBrowserClient; sessions: Se sessionsError: null, sessionsLoading: false, sidebarContent: null, - sidebarOpen: false, + sidebarLayout: { columns: [] }, chatScrollGeneration: 0, chatScrollCommitCleanup: null, handleChatScroll: vi.fn(), diff --git a/ui/src/pages/chat/chat-pane-lifecycle.ts b/ui/src/pages/chat/chat-pane-lifecycle.ts index 5abfa4f022e9..7daa34f08c12 100644 --- a/ui/src/pages/chat/chat-pane-lifecycle.ts +++ b/ui/src/pages/chat/chat-pane-lifecycle.ts @@ -417,6 +417,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneReset { this.paneResizeObserver?.disconnect(); this.paneResizeObserver = null; this.connectionGeneration += 1; + this.sessionDiscussionPanels.clear(); this.sessionCompanionHydrationKey = ""; this.taskSuggestionsRequestVersion += 1; this.taskSuggestions = []; diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index 34c22ccc1a85..48185432aa40 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -1,5 +1,4 @@ import { - CHAT_DETAIL_FULL_MESSAGE_MAX_CHARS, activeChatRunStartupStatus, areUiSessionKeysEquivalent, buildAgentMainSessionKey, @@ -31,7 +30,6 @@ import { renderChatControls, resolveActiveRunOutputTokens, resolveAssistantAttachmentAuthToken, - resolveBoardChatLayoutWidth, resolveChatAgentId, resolveChatAvatarUrl, resolveControlUiFollowUpMode, @@ -45,20 +43,33 @@ import { switchChatModel, switchChatThinkingLevel, t, + SIDEBAR_NARROW_BREAKPOINT_PX, + activatePanel, + closeSlot, + detachPanelToColumn, + isSidebarRegionCollapsed, + mergePanelIntoColumn, + sidebarPrimaryWidth, workspaceResultConflictFromPlacement, type BoardViewCallbacks, type ChatProps, - type DetailFullMessageResult, type SessionObserverDigest, - type SidebarFullMessageRequest, + type SidebarSide, + type SidebarSlotId, } from "./chat-pane-deps.ts"; import { ChatPaneHeaderRender } from "./chat-pane-header-render.ts"; import { - DETAIL_SIDEBAR_SIDE_MIN_WIDTH, SESSION_RAIL_DOCK_MIN_WIDTH, WORKSPACE_RAIL_MAX_WIDTH, WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH, } from "./chat-pane-shared.ts"; +import { + createSidebarFullMessageLoader, + renderSidebarRegion, + resolveSidebarLayoutForBoard, + restoreHiddenSidebarChat, +} from "./chat-pane-sidebar-layout.ts"; +import { renderChatImageLightbox } from "./components/chat-image-lightbox.ts"; export class ChatPaneRender extends ChatPaneHeaderRender { override render() { @@ -96,6 +107,12 @@ export class ChatPaneRender extends ChatPaneHeaderRender { ? workspaceConflict : undefined; const board = this.resolveBoardView(); + const sidebarLayout = resolveSidebarLayoutForBoard({ + board, + hasDetail: state.sidebarContent !== null, + layout: state.sidebarLayout, + paneWidth: this.paneWidth, + }); const runtimeConfigState = this.context.runtimeConfig.state; const configSnapshot = runtimeConfigState.configSnapshot; const serverQueueMode = resolveControlUiServerQueueMode(configSnapshot?.runtimeConfig, { @@ -168,13 +185,14 @@ export class ChatPaneRender extends ChatPaneHeaderRender { ? t("chat.catalog.remoteViewOnly") : t("chat.catalog.unsupportedViewOnly") : null; - const chatLayoutWidth = resolveBoardChatLayoutWidth({ - paneWidth: this.paneWidth, - hasBoard: board.hasBoard, - face: board.face, - dock: board.dock, - dockWidth: this.boardChatDockSize.width, - }); + const sidebarChatColumn = sidebarLayout.columns.find((column) => + column.panels.some((panel) => panel.slot === "chat"), + ); + const sidebarRegionCollapsed = isSidebarRegionCollapsed(sidebarLayout, this.paneWidth); + const primaryWidth = sidebarPrimaryWidth(sidebarLayout, this.paneWidth); + const chatLayoutWidth = sidebarRegionCollapsed + ? this.paneWidth + : (sidebarChatColumn?.width ?? primaryWidth); const sessionWorkspace = createSessionWorkspaceProps(state, { draftScope: this.paneId, narrowLayout: chatLayoutWidth < WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH, @@ -194,13 +212,10 @@ export class ChatPaneRender extends ChatPaneHeaderRender { }, }); const tasksSideDocked = !backgroundTasks.collapsed && !backgroundTasks.narrowLayout; - // Every side-docked rail narrows the room left for the chat + detail - // split; bottom strips do not. + // Side-docked workspace surfaces narrow the conversation region; bottom + // strips do not affect whether the session rail can dock beside it. const sideRailCount = (railSideDocked ? 1 : 0) + (tasksSideDocked ? 1 : 0); - const detailSplitWidth = chatLayoutWidth - sideRailCount * WORKSPACE_RAIL_MAX_WIDTH; - const sidebarStacked = detailSplitWidth < DETAIL_SIDEBAR_SIDE_MIN_WIDTH; - const chatMainWidth = - state.sidebarOpen && !sidebarStacked ? detailSplitWidth * state.splitRatio : detailSplitWidth; + const chatMainWidth = chatLayoutWidth - sideRailCount * WORKSPACE_RAIL_MAX_WIDTH; const selectedSessionRailMode = this.sessionRailModeSessionKey === state.sessionKey ? this.sessionRailMode : "hidden"; const gatewaySnapshot = this.context.gateway.snapshot; @@ -214,6 +229,7 @@ export class ChatPaneRender extends ChatPaneHeaderRender { activeRunIds: selectedSession?.activeRunIds, usageByRun: state.chatRunUsageById, }); + const loadSidebarFullMessage = createSidebarFullMessageLoader(state, Boolean(catalogKey)); const props: ChatProps = { transcript: this.transcript, paneId: this.paneId, @@ -513,38 +529,11 @@ export class ChatPaneRender extends ChatPaneHeaderRender { onSessionSelect: (next) => { this.onPaneSessionChange?.(this.paneId, next); }, - onLoadSidebarFullMessage: catalogKey - ? undefined - : async (request: SidebarFullMessageRequest): Promise => { - if (!state.client || !state.connected) { - return null; - } - return state.client.request("chat.message.get", { - sessionKey: request.sessionKey, - ...(request.agentId ? { agentId: request.agentId } : {}), - messageId: request.messageId, - maxChars: CHAT_DETAIL_FULL_MESSAGE_MAX_CHARS, - }); - }, - sidebarOpen: state.sidebarOpen, - sidebarContent: state.sidebarContent, - sidebarStacked, - splitRatio: state.splitRatio, canvasPluginSurfaceUrl: state.canvasPluginSurfaceUrl, boardProvider: board.provider, onOpenSidebar: state.handleOpenSidebar, - onCloseSidebar: () => { - const content = state.sidebarContent; - if (content?.kind === "session-discussion") { - this.sessionDiscussionOpenUrls.delete(content.sessionKey); - } - state.handleCloseSidebar(); - }, - imageLightbox: state.imageLightbox, onRequestOpenImage: state.beginImageOpen, onOpenImage: state.handleOpenImage, - onCloseImage: state.handleCloseImage, - onSplitRatioChange: state.handleSplitRatioChange, assistantName: state.assistantName, assistantAvatar: state.assistantAvatar, userId: selfUser?.id ?? null, @@ -560,7 +549,7 @@ export class ChatPaneRender extends ChatPaneHeaderRender { }; const chat = renderChat(props); const workboardCardChip = this.resolveWorkboardCardChip(board); - const content = + const primary = board.hasBoard && board.face === "dashboard" ? renderBoardSessionSurface({ snapshot: board.snapshot, @@ -576,9 +565,7 @@ export class ChatPaneRender extends ChatPaneHeaderRender { reopenDock: board.reopenDock, dockSize: this.boardChatDockSize, chat, - divider: this.renderBoardDivider( - board.dock === "hidden" ? board.reopenDock : board.dock, - ), + divider: this.renderBoardDivider("bottom"), canMutate: board.provider.canMutate, canGrant: board.provider.canGrant, callbacks: { @@ -598,6 +585,93 @@ export class ChatPaneRender extends ChatPaneHeaderRender { onDockChange: (dock) => this.handleBoardDockChange(dock), }) : chat; + const discussion = this.buildSessionDiscussionPanel(state, state.sessionKey.trim()); + const panelTemplates = { + chat, + ...(state.sidebarContent + ? { + detail: html` + openSessionWorkspaceFile(state, target)} + .onRevealInWorkspace=${(path: string) => revealSessionWorkspaceFile(state, path)} + .onOpenImage=${(item: Parameters[0]) => + state.handleOpenImage(item, state.beginImageOpen())} + .embedded=${true} + @chat-detail-panel-close=${() => state.handleCloseSidebar()} + >`, + } + : {}), + ...(discussion + ? { + discussion: html``, + } + : {}), + }; + const sidebarCallbacks = { + activatePanel: (panelId: string) => { + state.updateSidebarLayout(activatePanel(state.sidebarLayout, panelId)); + state.updateSidebarActivePanel(panelId); + }, + closeSlot: (slot: SidebarSlotId) => { + if (slot === "chat") { + this.handleBoardDockChange("hidden"); + return; + } + if (slot === "discussion") { + this.sessionDiscussionOpenUrls.delete(state.sessionKey.trim()); + } + state.updateSidebarLayout(closeSlot(state.sidebarLayout, slot)); + }, + detachPanel: (panelId: string, side: SidebarSide, columnIndex: number) => { + const moved = restoreHiddenSidebarChat({ + activatedPanelId: panelId, + movedLayout: detachPanelToColumn(sidebarLayout, panelId, side, columnIndex), + renderedLayout: sidebarLayout, + storedLayout: state.sidebarLayout, + }); + this.commitSidebarPanelMove(moved, panelId, side, board); + }, + mergePanel: (panelId: string, targetColumnId: string, panelIndex: number) => { + const target = sidebarLayout.columns.find((column) => column.id === targetColumnId); + const merged = restoreHiddenSidebarChat({ + activatedPanelId: panelId, + movedLayout: mergePanelIntoColumn(sidebarLayout, panelId, targetColumnId, panelIndex), + renderedLayout: sidebarLayout, + storedLayout: state.sidebarLayout, + }); + if (target) { + this.commitSidebarPanelMove(merged, panelId, target.side, board); + } + }, + resizeColumn: (columnId: string, width: number) => { + this.commitSidebarColumnResize(sidebarLayout, columnId, width); + }, + }; + const content = renderSidebarRegion({ + availableWidth: this.paneWidth, + callbacks: sidebarCallbacks, + discussionOpenUrl: discussion?.openUrl ?? null, + focusPanelId: state.sidebarFocusPanelId, + focusVersion: state.sidebarFocusVersion, + layout: sidebarLayout, + narrow: this.paneWidth < SIDEBAR_NARROW_BREAKPOINT_PX, + panelTemplates, + primary, + sessionKey: state.sessionKey, + }); return html`${this.renderPaneHeader( sessionWorkspace, backgroundTasks, @@ -605,6 +679,9 @@ export class ChatPaneRender extends ChatPaneHeaderRender { Boolean(catalogKey), selectedAgent?.workspace, selectedAgent?.workspaceGit === true, - )}${content}${this.renderResetConfirmation()}`; + )}${content}${renderChatImageLightbox( + state.imageLightbox, + state.handleCloseImage, + )}${this.renderResetConfirmation()}`; } } diff --git a/ui/src/pages/chat/chat-pane-shared.ts b/ui/src/pages/chat/chat-pane-shared.ts index 8b19197fa8b4..006ca1cce0d5 100644 --- a/ui/src/pages/chat/chat-pane-shared.ts +++ b/ui/src/pages/chat/chat-pane-shared.ts @@ -118,11 +118,7 @@ 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; -// .chat-main min-width (312) + divider + .chat-sidebar min-width (300) + slack; -// below this the detail panel stacks under the thread. -export const DETAIL_SIDEBAR_SIDE_MIN_WIDTH = 680; export const SESSION_RAIL_DOCK_MIN_WIDTH = 1080; - export const NEW_SESSION_ACTIVE_RUN_MESSAGE = "Start a new thread after the active run or queued messages finish."; export const NEW_SESSION_LIST_LOADING_MESSAGE = diff --git a/ui/src/pages/chat/chat-pane-sidebar-layout.test.ts b/ui/src/pages/chat/chat-pane-sidebar-layout.test.ts new file mode 100644 index 000000000000..b8a2dab82161 --- /dev/null +++ b/ui/src/pages/chat/chat-pane-sidebar-layout.test.ts @@ -0,0 +1,208 @@ +/* @vitest-environment jsdom */ + +import { html, render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import type { ResolvedBoardView } from "./chat-pane-shared.ts"; +import { + renderSidebarRegion, + resolveSidebarLayoutForBoard, + restoreHiddenSidebarChat, +} from "./chat-pane-sidebar-layout.ts"; +import { + closeSlot, + detachPanelToColumn, + mergePanelIntoColumn, + openSlot, +} from "./sidebar-layout.ts"; + +function board(dock: ResolvedBoardView["dock"], face: ResolvedBoardView["face"] = "dashboard") { + return { + hasBoard: true, + face, + dock, + } as ResolvedBoardView; +} + +describe("chat pane sidebar layout", () => { + it("preserves the primary DOM across open, close, and reopen", async () => { + const container = document.createElement("div"); + document.body.append(container); + const callbacks = { + activatePanel: vi.fn(), + closeSlot: vi.fn(), + detachPanel: vi.fn(), + mergePanel: vi.fn(), + resizeColumn: vi.fn(), + }; + const renderLayout = async ( + layout: ReturnType | { columns: [] }, + narrow = false, + ) => { + render( + renderSidebarRegion({ + availableWidth: narrow ? 620 : 1_400, + callbacks, + discussionOpenUrl: null, + focusPanelId: "", + focusVersion: 0, + layout, + narrow, + panelTemplates: { detail: html`` }, + primary: html`
Primary
`, + sessionKey: "agent:main:current", + }), + container, + ); + }; + + await renderLayout({ columns: [] }); + const primary = container.querySelector("[data-primary]"); + await renderLayout(openSlot({ columns: [] }, "detail")); + expect(container.querySelector("[data-primary]")).toBe(primary); + await customElements.whenDefined("openclaw-chat-sidebar-region"); + await container.querySelector("openclaw-chat-sidebar-region")?.updateComplete; + expect(container.querySelector("[data-primary]")).toBe(primary); + const rightTab = container.querySelector(".sidebar-region__right-runtime .sidebar-column__tab"); + expect(rightTab).not.toBeNull(); + expect(primary!.compareDocumentPosition(rightTab!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + await renderLayout({ columns: [] }); + expect(container.querySelector("[data-primary]")).toBe(primary); + await renderLayout(openSlot({ columns: [] }, "detail")); + expect(container.querySelector("[data-primary]")).toBe(primary); + await renderLayout(openSlot({ columns: [] }, "detail"), true); + await container.querySelector("openclaw-chat-sidebar-region")?.updateComplete; + expect(container.querySelector("[data-primary]")).toBe(primary); + await renderLayout(openSlot({ columns: [] }, "detail")); + await container.querySelector("openclaw-chat-sidebar-region")?.updateComplete; + expect(container.querySelector("[data-primary]")).toBe(primary); + + container.remove(); + }); + + it("promotes side-docked dashboard chat into the requested side", () => { + const layout = resolveSidebarLayoutForBoard({ + board: board("left"), + hasDetail: false, + layout: { columns: [] }, + paneWidth: 1_400, + }); + expect(layout.columns[0]?.side).toBe("left"); + expect(layout.columns[0]?.panels[0]?.slot).toBe("chat"); + }); + + it("keeps an unmeasured wide shell aligned with the sidebar runtime", () => { + const container = document.createElement("div"); + render( + renderSidebarRegion({ + availableWidth: 0, + callbacks: { + activatePanel: vi.fn(), + closeSlot: vi.fn(), + detachPanel: vi.fn(), + mergePanel: vi.fn(), + resizeColumn: vi.fn(), + }, + discussionOpenUrl: null, + focusPanelId: "", + focusVersion: 0, + layout: openSlot({ columns: [] }, "detail"), + narrow: false, + panelTemplates: { detail: html`` }, + primary: html`
Primary
`, + sessionKey: "agent:main:current", + }), + container, + ); + + expect(container.querySelector(".sidebar-region--narrow")).toBeNull(); + }); + + it("keeps bottom chat outside the sidebar model", () => { + const layout = resolveSidebarLayoutForBoard({ + board: board("bottom"), + hasDetail: true, + layout: openSlot(openSlot({ columns: [] }, "chat"), "detail"), + paneWidth: 1_400, + }); + expect(layout.columns.flatMap((column) => column.panels.map((panel) => panel.slot))).toEqual([ + "detail", + ]); + }); + + it("drops stale detail placement when no transient detail is available", () => { + const layout = resolveSidebarLayoutForBoard({ + board: board("hidden", "chat"), + hasDetail: false, + layout: openSlot({ columns: [] }, "detail"), + paneWidth: 1_400, + }); + expect(layout).toEqual({ columns: [] }); + }); + + it("preserves stored chat placement when moving panels in a hidden-chat projection", () => { + const stored = openSlot( + openSlot(openSlot({ columns: [] }, "chat", "left"), "detail"), + "discussion", + ); + const rendered = closeSlot(stored, "chat"); + const detail = rendered.columns + .flatMap((column) => column.panels) + .find((panel) => panel.slot === "detail")!; + const discussionColumn = rendered.columns.find((column) => + column.panels.some((panel) => panel.slot === "discussion"), + )!; + const movedProjection = mergePanelIntoColumn(rendered, detail.id, discussionColumn.id, 0); + const moved = restoreHiddenSidebarChat({ + activatedPanelId: detail.id, + movedLayout: movedProjection, + renderedLayout: rendered, + storedLayout: stored, + }); + + expect(moved.columns.flatMap((column) => column.panels.map((panel) => panel.slot))).toEqual([ + "chat", + "detail", + "discussion", + ]); + }); + + it("preserves the stored active chat tab across an unrelated projected move", () => { + let stored = openSlot(openSlot(openSlot({ columns: [] }, "chat"), "detail"), "discussion"); + const chatColumn = stored.columns.find((column) => + column.panels.some((panel) => panel.slot === "chat"), + )!; + const chatPanel = chatColumn.panels.find((panel) => panel.slot === "chat")!; + const detailPanel = stored.columns + .flatMap((column) => column.panels) + .find((panel) => panel.slot === "detail")!; + const discussionPanel = stored.columns + .flatMap((column) => column.panels) + .find((panel) => panel.slot === "discussion")!; + stored = mergePanelIntoColumn(stored, detailPanel.id, chatColumn.id, 1); + stored = mergePanelIntoColumn(stored, discussionPanel.id, chatColumn.id, 2); + stored.columns.find((column) => column.id === chatColumn.id)!.activePanelId = chatPanel.id; + const rendered = closeSlot(stored, "chat"); + const movedProjection = detachPanelToColumn(rendered, detailPanel.id, "right", 0); + + const moved = restoreHiddenSidebarChat({ + activatedPanelId: detailPanel.id, + movedLayout: movedProjection, + renderedLayout: rendered, + storedLayout: stored, + }); + + expect(moved.columns.find((column) => column.id === chatColumn.id)?.activePanelId).toBe( + chatPanel.id, + ); + }); + + it("refits ordinary chat columns to preserve the primary minimum", () => { + const layout = resolveSidebarLayoutForBoard({ + board: board("hidden", "chat"), + hasDetail: true, + layout: openSlot(openSlot({ columns: [] }, "detail"), "discussion"), + paneWidth: 1_000, + }); + expect(layout.columns.reduce((sum, column) => sum + column.width, 0)).toBe(680); + }); +}); diff --git a/ui/src/pages/chat/chat-pane-sidebar-layout.ts b/ui/src/pages/chat/chat-pane-sidebar-layout.ts new file mode 100644 index 000000000000..f89aef77f708 --- /dev/null +++ b/ui/src/pages/chat/chat-pane-sidebar-layout.ts @@ -0,0 +1,210 @@ +import { html, type TemplateResult } from "lit"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { ResolvedBoardView } from "./chat-pane-shared.ts"; +import type { + SidebarPanelTemplates, + SidebarRegionCallbacks, +} from "./components/chat-sidebar-region-types.ts"; +import type { + DetailFullMessageResult, + SidebarFullMessageRequest, +} from "./components/chat-sidebar.ts"; +import { + closeSlot, + detachPanelToColumn, + fitSidebarLayout, + isSidebarRegionCollapsed, + openSlot, + type SidebarColumn, + type SidebarLayout, + type SidebarPanel, +} from "./sidebar-layout.ts"; + +const DETAIL_FULL_MESSAGE_MAX_CHARS = 500_000; +let sidebarRegionLoad: Promise | null = null; + +export function renderSidebarRegion(params: { + availableWidth: number; + callbacks: SidebarRegionCallbacks; + discussionOpenUrl: string | null; + focusPanelId: string; + focusVersion: number; + layout: SidebarLayout; + narrow: boolean; + panelTemplates: SidebarPanelTemplates; + primary: TemplateResult; + sessionKey: string; +}): TemplateResult { + const hasPanels = params.layout.columns.some((column) => column.panels.length > 0); + if (hasPanels && !customElements.get("openclaw-chat-sidebar-region")) { + sidebarRegionLoad ??= import("./components/chat-sidebar-region.runtime.ts").then( + () => true, + () => { + sidebarRegionLoad = null; + return false; + }, + ); + } + const availableWidth = + params.availableWidth > 0 ? params.availableWidth : Number.POSITIVE_INFINITY; + const collapsed = params.narrow || isSidebarRegionCollapsed(params.layout, availableWidth); + return html``; +} + +export function resolveSidebarLayoutForBoard(params: { + board: ResolvedBoardView; + hasDetail: boolean; + layout: SidebarLayout; + paneWidth: number; +}): SidebarLayout { + let layout = params.hasDetail ? params.layout : closeSlot(params.layout, "detail"); + const chatSide = + params.board.hasBoard && + params.board.face === "dashboard" && + (params.board.dock === "left" || params.board.dock === "right") + ? params.board.dock + : null; + if (!chatSide) { + layout = closeSlot(layout, "chat"); + return fitSidebarLayout(layout, params.paneWidth) ?? layout; + } + const beforeOpen = layout; + layout = openSlot(layout, "chat", chatSide); + const chatColumn = layout.columns.find((column) => + column.panels.some((panel) => panel.slot === "chat"), + ); + if (chatColumn && chatColumn.side !== chatSide) { + const chatPanel = chatColumn.panels.find((panel) => panel.slot === "chat"); + if (chatPanel) { + layout = detachPanelToColumn(layout, chatPanel.id, chatSide, 0); + } + } + const newColumn = layout.columns.find( + (column) => !beforeOpen.columns.some((current) => current.id === column.id), + ); + return fitSidebarLayout(layout, params.paneWidth, newColumn?.id) ?? layout; +} + +function stableInsertionIndex(order: string[], current: string[], targetId: string): number { + const targetIndex = order.indexOf(targetId); + for (let index = targetIndex - 1; index >= 0; index -= 1) { + const currentIndex = current.indexOf(order[index]!); + if (currentIndex >= 0) { + return currentIndex + 1; + } + } + for (let index = targetIndex + 1; index < order.length; index += 1) { + const currentIndex = current.indexOf(order[index]!); + if (currentIndex >= 0) { + return currentIndex; + } + } + return Math.max(0, Math.min(targetIndex, current.length)); +} + +export function restoreHiddenSidebarChat(params: { + activatedPanelId: string; + movedLayout: SidebarLayout; + renderedLayout: SidebarLayout; + storedLayout: SidebarLayout; +}): SidebarLayout { + const renderedHasChat = params.renderedLayout.columns.some((column) => + column.panels.some((panel) => panel.slot === "chat"), + ); + if (renderedHasChat) { + return params.movedLayout; + } + let storedColumn: SidebarColumn | undefined; + let storedPanel: SidebarPanel | undefined; + for (const column of params.storedLayout.columns) { + const chat = column.panels.find((panel) => panel.slot === "chat"); + if (chat) { + storedColumn = column; + storedPanel = chat; + break; + } + } + if (!storedColumn || !storedPanel) { + return params.movedLayout; + } + if ( + params.movedLayout.columns.some((column) => + column.panels.some((panel) => panel.id === storedPanel.id), + ) + ) { + return params.movedLayout; + } + const existingColumnIndex = params.movedLayout.columns.findIndex( + (column) => column.id === storedColumn.id, + ); + if (existingColumnIndex >= 0) { + const columns = [...params.movedLayout.columns]; + const column = columns[existingColumnIndex]!; + const panelIndex = stableInsertionIndex( + storedColumn.panels.map((panel) => panel.id), + column.panels.map((panel) => panel.id), + storedPanel.id, + ); + const panels = [...column.panels]; + panels.splice(panelIndex, 0, storedPanel); + const moveActivatedThisColumn = column.panels.some( + (panel) => panel.id === params.activatedPanelId, + ); + columns[existingColumnIndex] = { + ...column, + panels, + activePanelId: + storedColumn.activePanelId === storedPanel.id && !moveActivatedThisColumn + ? storedPanel.id + : column.activePanelId, + }; + return { columns }; + } + const columns = [...params.movedLayout.columns]; + const columnIndex = stableInsertionIndex( + params.storedLayout.columns.map((column) => column.id), + columns.map((column) => column.id), + storedColumn.id, + ); + columns.splice(columnIndex, 0, { + ...storedColumn, + panels: [storedPanel], + activePanelId: storedPanel.id, + }); + return { columns }; +} + +export function createSidebarFullMessageLoader( + state: { client: GatewayBrowserClient | null; connected: boolean }, + disabled: boolean, +): ((request: SidebarFullMessageRequest) => Promise) | null { + if (disabled) { + return null; + } + return async (request) => { + if (!state.client || !state.connected) { + return null; + } + return state.client.request("chat.message.get", { + sessionKey: request.sessionKey, + ...(request.agentId ? { agentId: request.agentId } : {}), + messageId: request.messageId, + maxChars: DETAIL_FULL_MESSAGE_MAX_CHARS, + }); + }; +} diff --git a/ui/src/pages/chat/chat-pane.message-cut.test.ts b/ui/src/pages/chat/chat-pane.message-cut.test.ts index b47c926add0e..e674f58bbaf8 100644 --- a/ui/src/pages/chat/chat-pane.message-cut.test.ts +++ b/ui/src/pages/chat/chat-pane.message-cut.test.ts @@ -68,7 +68,7 @@ function createTestChatPane(params: { client: GatewayBrowserClient; sessions: Se sessionsError: null, sessionsLoading: false, sidebarContent: null, - sidebarOpen: false, + sidebarLayout: { columns: [] }, // Minimal scroll host so scheduleChatScroll is a no-op instead of throwing. chatScrollGeneration: 0, chatScrollCommitCleanup: null, diff --git a/ui/src/pages/chat/chat-pane.session-discussion.test.ts b/ui/src/pages/chat/chat-pane.session-discussion.test.ts index b4095a60efb3..1aed47070b46 100644 --- a/ui/src/pages/chat/chat-pane.session-discussion.test.ts +++ b/ui/src/pages/chat/chat-pane.session-discussion.test.ts @@ -1,24 +1,30 @@ /* @vitest-environment jsdom */ -import { render } from "lit"; +import { html, render } from "lit"; import { describe, expect, it, vi } from "vitest"; import type { SessionDiscussionInfo } from "../../../../packages/gateway-protocol/src/index.js"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { SessionCapability } from "../../lib/sessions/index.ts"; import { createTestChatPane, type TestChatPane } from "./chat-pane.test-support.ts"; -import type { SidebarContent } from "./components/chat-sidebar.ts"; -import "./components/chat-sidebar.ts"; +import type { SessionDiscussionPanelConfig } from "./components/session-discussion-panel.ts"; +import "./components/session-discussion-panel.ts"; +import { openSlot } from "./sidebar-layout.ts"; type DiscussionTestPane = TestChatPane & { + buildSessionDiscussionPanel: ( + state: ReturnType["state"], + sessionKey: string, + ) => SessionDiscussionPanelConfig | null; probeSessionDiscussion: (sessionKey: string) => Promise; renderSessionDiscussionAction: () => unknown; + paneWidth: number; }; const SESSION_KEY = "agent:main:current"; function createDiscussionPane(params: { info: SessionDiscussionInfo | Promise; - sidebarOpen?: boolean; + detailOpen?: boolean; }) { const request = vi.fn().mockImplementation(async (method: string) => { if (method === "session.discussion.info") { @@ -33,81 +39,85 @@ function createDiscussionPane(params: { (pane.context.gateway.snapshot as { hello: unknown }).hello = { features: { methods: ["session.discussion.info", "session.discussion.open"] }, }; - const handleOpenSidebar = vi.fn((content: SidebarContent) => { - state.sidebarContent = content; - state.sidebarOpen = true; + state.sidebarLayout = params.detailOpen ? openSlot({ columns: [] }, "detail") : { columns: [] }; + const updateSidebarLayout = vi.fn((layout) => { + state.sidebarLayout = layout; }); - const handleCloseSidebar = vi.fn(() => { - state.sidebarOpen = false; - }); - state.handleOpenSidebar = handleOpenSidebar; - state.handleCloseSidebar = handleCloseSidebar; - state.sidebarOpen = params.sidebarOpen ?? false; - return { pane, state, handleOpenSidebar, handleCloseSidebar, request }; + state.updateSidebarLayout = updateSidebarLayout; + return { pane, state, updateSidebarLayout, request }; } describe("chat pane session discussion auto-show", () => { - it("auto-shows the sidebar when the probe reports an open discussion", async () => { - const { pane, handleOpenSidebar } = createDiscussionPane({ + it("auto-shows the discussion slot when the probe reports an open discussion", async () => { + const { pane, state, updateSidebarLayout } = createDiscussionPane({ info: { state: "open", embedUrl: "https://clack.example/embed/c1" }, }); await pane.probeSessionDiscussion(SESSION_KEY); - expect(handleOpenSidebar).toHaveBeenCalledTimes(1); - const content = handleOpenSidebar.mock.calls[0]?.[0]; - expect(content?.kind).toBe("session-discussion"); - expect(content && "sessionKey" in content ? content.sessionKey : null).toBe(SESSION_KEY); + expect(updateSidebarLayout).toHaveBeenCalledTimes(1); + expect( + state.sidebarLayout.columns.flatMap((column) => column.panels.map((panel) => panel.slot)), + ).toEqual(["discussion"]); }); - it("shows the reported external URL in the outer sidebar header", async () => { + it("keeps the reported external URL with the promoted discussion panel", async () => { const openUrl = "https://clack.example/channels/c1"; - const { pane, state, handleOpenSidebar } = createDiscussionPane({ - info: { - state: "open", - embedUrl: "https://clack.example/embed/c1", - openUrl, - }, + const { pane, state } = createDiscussionPane({ + info: { state: "open", embedUrl: "https://clack.example/embed/c1", openUrl }, }); await pane.probeSessionDiscussion(SESSION_KEY); + pane + .buildSessionDiscussionPanel(state, SESSION_KEY) + ?.onStateChange(SESSION_KEY, "open", openUrl); - const content = handleOpenSidebar.mock.calls[0]?.[0]; - if (!content || content.kind !== "session-discussion") { - throw new Error("expected a session discussion sidebar"); - } - content.onStateChange(SESSION_KEY, "open", openUrl); + expect(pane.buildSessionDiscussionPanel(state, SESSION_KEY)?.openUrl).toBe(openUrl); + }); - const panel = document.createElement("openclaw-chat-detail-panel") as HTMLElement & { - content: SidebarContent; - onClose: () => void; - updateComplete: Promise; + it("does not reload discussion info when the pane renders unchanged config twice", async () => { + const { pane, state, request } = createDiscussionPane({ + info: { state: "open", embedUrl: "https://clack.example/embed/c1" }, + }); + const container = document.createElement("div"); + document.body.append(container); + + const renderPanel = async () => { + const config = pane.buildSessionDiscussionPanel(state, SESSION_KEY)!; + render( + html``, + container, + ); + await container.querySelector("openclaw-session-discussion")?.updateComplete; }; - panel.content = state.sidebarContent as SidebarContent; - panel.onClose = vi.fn(); - document.body.append(panel); - await panel.updateComplete; - const external = panel.querySelector(".sidebar-header a"); - expect(external?.href).toBe(openUrl); - expect(external?.target).toBe("_blank"); - expect(external?.rel).toBe("noopener"); - expect(panel.querySelector(".session-discussion__header")).toBeNull(); - panel.remove(); + await renderPanel(); + await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1)); + await renderPanel(); + expect(request).toHaveBeenCalledTimes(1); + + container.remove(); }); it("does not auto-show for a merely available discussion", async () => { - const { pane, handleOpenSidebar } = createDiscussionPane({ + const { pane, updateSidebarLayout } = createDiscussionPane({ info: { state: "available" }, }); await pane.probeSessionDiscussion(SESSION_KEY); - expect(handleOpenSidebar).not.toHaveBeenCalled(); + expect(updateSidebarLayout).not.toHaveBeenCalled(); }); - it("uses the header action to open and close the discussion sidebar", async () => { - const { pane, handleOpenSidebar, handleCloseSidebar } = createDiscussionPane({ + it("uses the header action to open and close the discussion slot", async () => { + const { pane, state, updateSidebarLayout } = createDiscussionPane({ info: { state: "available" }, }); const container = document.createElement("div"); @@ -120,33 +130,76 @@ describe("chat pane session discussion auto-show", () => { expect(action?.ariaLabel).toBe("Show discussion"); expect(action?.getAttribute("aria-pressed")).toBe("false"); action?.click(); - expect(handleOpenSidebar).toHaveBeenCalledTimes(1); + expect(updateSidebarLayout).toHaveBeenCalledTimes(1); render(pane.renderSessionDiscussionAction(), container); action = container.querySelector(".chat-session-discussion-toggle"); expect(action?.ariaLabel).toBe("Hide discussion"); expect(action?.getAttribute("aria-pressed")).toBe("true"); action?.click(); - expect(handleCloseSidebar).toHaveBeenCalledTimes(1); - expect(handleOpenSidebar).toHaveBeenCalledTimes(1); + expect(state.sidebarLayout.columns).toEqual([]); + expect(updateSidebarLayout).toHaveBeenCalledTimes(2); container.remove(); }); - it("does not steal a sidebar that is already open", async () => { - const { pane, handleOpenSidebar } = createDiscussionPane({ + it("opens beside an existing detail slot without stealing it", async () => { + const { pane, state } = createDiscussionPane({ info: { state: "open", embedUrl: "https://clack.example/embed/c1" }, - sidebarOpen: true, + detailOpen: true, }); await pane.probeSessionDiscussion(SESSION_KEY); - expect(handleOpenSidebar).not.toHaveBeenCalled(); + expect( + state.sidebarLayout.columns.flatMap((column) => column.panels.map((panel) => panel.slot)), + ).toEqual(["detail", "discussion"]); + }); + + it("opens as a collapsed tab when two columns cannot fit side by side", async () => { + const { pane, state } = createDiscussionPane({ + info: { state: "open", embedUrl: "https://clack.example/embed/c1" }, + detailOpen: true, + }); + pane.paneWidth = 700; + + await pane.probeSessionDiscussion(SESSION_KEY); + + expect( + state.sidebarLayout.columns.flatMap((column) => column.panels.map((panel) => panel.slot)), + ).toEqual(["detail", "discussion"]); + }); + + it("ignores a stale none callback after switching sessions", async () => { + const { pane, state } = createDiscussionPane({ + info: { state: "open", embedUrl: "https://clack.example/embed/c1" }, + }); + await pane.probeSessionDiscussion(SESSION_KEY); + const stalePanel = pane.buildSessionDiscussionPanel(state, SESSION_KEY); + state.sessionKey = "agent:main:other"; + state.sidebarLayout = openSlot({ columns: [] }, "discussion"); + + stalePanel?.onStateChange(SESSION_KEY, "none", null); + + expect(state.sidebarLayout.columns[0]?.panels[0]?.slot).toBe("discussion"); + }); + + it("preserves discussion placement across a reconnect", async () => { + const { pane, state } = createDiscussionPane({ info: { state: "available" } }); + state.sidebarLayout = openSlot({ columns: [] }, "discussion"); + + pane.applyGatewaySnapshot({ + ...pane.context.gateway.snapshot, + phase: "reconnecting", + hello: null, + }); + + expect(state.sidebarLayout.columns[0]?.panels[0]?.slot).toBe("discussion"); }); it("does not auto-show when the pane switched sessions before the probe resolved", async () => { let resolveInfo!: (value: SessionDiscussionInfo) => void; - const { pane, state, handleOpenSidebar } = createDiscussionPane({ + const { pane, state, updateSidebarLayout } = createDiscussionPane({ info: new Promise((resolve) => { resolveInfo = resolve; }), @@ -157,6 +210,6 @@ describe("chat pane session discussion auto-show", () => { resolveInfo({ state: "open", embedUrl: "https://clack.example/embed/c1" }); await probe; - expect(handleOpenSidebar).not.toHaveBeenCalled(); + expect(updateSidebarLayout).not.toHaveBeenCalled(); }); }); diff --git a/ui/src/pages/chat/chat-pane.test-support.ts b/ui/src/pages/chat/chat-pane.test-support.ts index be11a0fa72b6..b18570a9277b 100644 --- a/ui/src/pages/chat/chat-pane.test-support.ts +++ b/ui/src/pages/chat/chat-pane.test-support.ts @@ -169,7 +169,9 @@ export function createTestChatPane(params: { sessionsError: null, sessionsLoading: false, sidebarContent: null, - sidebarOpen: false, + sidebarFocusPanelId: "", + sidebarFocusVersion: 0, + sidebarLayout: { columns: [] }, // Minimal scroll host so scheduleChatScroll is a no-op instead of throwing. chatScrollGeneration: 0, chatScrollCommitCleanup: null, @@ -178,6 +180,13 @@ export function createTestChatPane(params: { resetToolStream: vi.fn(), renderLifecycle: { afterCommit: () => () => {}, invalidate: () => {} }, } as unknown as ChatPageHost; + state.updateSidebarLayout = (layout) => { + state.sidebarLayout = layout; + }; + state.updateSidebarActivePanel = (panelId) => { + state.sidebarFocusPanelId = panelId; + state.sidebarFocusVersion += 1; + }; pane.context = createSessionContext(params.client, params.sessions); pane.state = state; pane.connectedClient = params.client; diff --git a/ui/src/pages/chat/chat-pane.test.ts b/ui/src/pages/chat/chat-pane.test.ts index d67e0b30e4b5..dcd6e845d586 100644 --- a/ui/src/pages/chat/chat-pane.test.ts +++ b/ui/src/pages/chat/chat-pane.test.ts @@ -27,6 +27,7 @@ import { createBackgroundTasksProps } from "./components/chat-background-tasks.t import { createSessionWorkspaceProps } from "./components/chat-session-workspace.ts"; import type { SidebarContent } from "./components/chat-sidebar.ts"; import { cacheChatSessionSnapshot, type ChatMessageCache } from "./session-message-cache.ts"; +import { openSlot } from "./sidebar-layout.ts"; afterEach(() => { vi.unstubAllGlobals(); @@ -595,7 +596,7 @@ describe("chat pane keyboard shortcuts", () => { pane.active = true; state.connected = false; state.sidebarContent = canvasContent; - state.sidebarOpen = true; + state.sidebarLayout = openSlot({ columns: [] }, "detail"); expect(createSessionWorkspaceProps(state).collapsed).toBe(true); @@ -603,14 +604,14 @@ describe("chat pane keyboard shortcuts", () => { expect(expandEvent.defaultPrevented).toBe(true); expect(createSessionWorkspaceProps(state).collapsed).toBe(false); - expect(state.sidebarOpen).toBe(true); + expect(state.sidebarLayout.columns[0]?.panels[0]?.slot).toBe("detail"); expect(state.sidebarContent).toBe(canvasContent); const collapseEvent = dispatchSidebarShortcut(pane); expect(collapseEvent.defaultPrevented).toBe(true); expect(createSessionWorkspaceProps(state).collapsed).toBe(true); - expect(state.sidebarOpen).toBe(true); + expect(state.sidebarLayout.columns[0]?.panels[0]?.slot).toBe("detail"); expect(state.sidebarContent).toBe(canvasContent); const mainSidebarEvent = dispatchSidebarShortcut(pane, false); diff --git a/ui/src/pages/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts index 3c02407a1cdf..39c809f19de6 100644 --- a/ui/src/pages/chat/chat-responsive.browser.test.ts +++ b/ui/src/pages/chat/chat-responsive.browser.test.ts @@ -1839,40 +1839,35 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { }, ); - it("stacks the detail sidebar below the thread in a narrow pane", async () => { + it("collapses sidebar columns into one tabbed column below the pane breakpoint", async () => { const page = await openBrowserPage(900, 700); try { - // A 620px pane inside a wide viewport: chat-pane sets the stacked class - // when the pane cannot fit chat + detail panel side by side. await page.setContent( `
-
-
-
-
-
-
A
-
-
Stacked layout keeps the thread readable.
-
-
+ -
+ +
`, ); await expectNoHorizontalOverflow(page); - const main = await getRect(page, ".chat-main"); - const sidebar = await getRect(page, ".chat-sidebar"); - expect(sidebar.top).toBeGreaterThanOrEqual(main.bottom - 1); - expect(Math.abs(sidebar.width - main.width)).toBeLessThanOrEqual(1); + const primary = await getRect(page, ".sidebar-region__primary"); + const sidebar = await getRect(page, ".sidebar-column--collapsed"); + expect(sidebar.top).toBeGreaterThanOrEqual(primary.bottom - 1); + expect(Math.abs(sidebar.width - primary.width)).toBeLessThanOrEqual(1); expect(sidebar.width).toBeGreaterThanOrEqual(618); - expect(sidebar.height).toBeGreaterThanOrEqual(160); + expect(await page.locator(".sidebar-column__tab").count()).toBe(2); } finally { await closeBrowserPage(page); } diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index 3eb208518cfa..9b095b8d449d 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -395,7 +395,6 @@ function makeHost(overrides?: MakeHostOverrides): TestChatHost | TestChatHostWit chatShowToolCalls: next.chatShowToolCalls, chatPersistCommentary: next.chatPersistCommentary, chatSendShortcut: next.chatSendShortcut, - splitRatio: next.splitRatio, }); }), ...hostOverrides, diff --git a/ui/src/pages/chat/chat-session-rail.test.ts b/ui/src/pages/chat/chat-session-rail.test.ts index 8fc66a8a7c7c..9ceec966b3fd 100644 --- a/ui/src/pages/chat/chat-session-rail.test.ts +++ b/ui/src/pages/chat/chat-session-rail.test.ts @@ -204,7 +204,7 @@ describe("ChatSessionRailElement", () => { }); async function mount(overrides: Partial = {}) { - const element = new ChatSessionRailElement(); + const element = document.createElement("openclaw-chat-session-rail") as ChatSessionRailElement; element.sessionKey = "agent:main:run"; element.digest = digest(); element.running = true; diff --git a/ui/src/pages/chat/chat-state-host.ts b/ui/src/pages/chat/chat-state-host.ts index 4695764c9fe2..89ad25f668ed 100644 --- a/ui/src/pages/chat/chat-state-host.ts +++ b/ui/src/pages/chat/chat-state-host.ts @@ -23,6 +23,7 @@ import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "./inpu import type { RenderLifecycle } from "./render-lifecycle.ts"; import type { PendingChatAbort } from "./run-lifecycle.ts"; import type { ChatMessageCache } from "./session-message-cache.ts"; +import type { SidebarLayout } from "./sidebar-layout.ts"; import type { CompactionStatus, FallbackStatus, @@ -115,11 +116,13 @@ export type ChatPageHost = ChatHost & chatIsProgrammaticScroll: boolean; chatProgrammaticScrollTarget: number; chatScrollToEnd?: (options: { behavior?: ScrollBehavior }) => void; - sidebarOpen: boolean; + sidebarLayout: SidebarLayout; sidebarContent: SidebarContent | null; + sidebarFocusPanelId: string; + sidebarFocusVersion: number; + updateSidebarActivePanel: (panelId: string) => void; imageLightbox: ImageLightboxItem | null; imageLightboxRequestVersion: number; - splitRatio: number; querySelector: (selectors: string) => Element | null; renderLifecycle: RenderLifecycle; onModelChanged: () => Promise | void; @@ -142,10 +145,10 @@ export type ChatPageHost = ChatHost & retryQueuedChatMessage: (id: string) => Promise; steerQueuedChatMessage: (id: string) => Promise; handleCloseSidebar: () => void; + updateSidebarLayout: (layout: SidebarLayout) => void; beginImageOpen: () => number; handleOpenImage: (item: ImageLightboxItem, requestVersion?: number) => void; handleCloseImage: () => void; - handleSplitRatioChange: (ratio: number) => void; announceSessionSwitch?: (sessionKey: string, label: string) => void; createChatSession?: () => Promise; confirmConversationReset?: () => Promise; diff --git a/ui/src/pages/chat/chat-state-page.ts b/ui/src/pages/chat/chat-state-page.ts index c6c10199a6c9..235ed56b1b02 100644 --- a/ui/src/pages/chat/chat-state-page.ts +++ b/ui/src/pages/chat/chat-state-page.ts @@ -4,6 +4,7 @@ import { fetchAssistantIdentity } from "../../app/assistant-identity.ts"; import type { ApplicationContext } from "../../app/context.ts"; import { loadLocalUserIdentity, loadSettings, patchSettings } from "../../app/settings.ts"; import { resolveSafeExternalUrl } from "../../lib/open-external-url.ts"; +import { canonicalUiSessionKeyForPersistence } from "../../lib/sessions/session-key.ts"; import { removeQueuedMessage } from "./chat-queue.ts"; import { attachChatRealtimeActions, createInitialChatRealtimeState } from "./chat-realtime.ts"; import { @@ -22,9 +23,22 @@ import type { RenderLifecycle } from "./render-lifecycle.ts"; import { handleAbortChat } from "./run-lifecycle.ts"; import { handleChatScroll, resetChatScroll, scheduleChatScroll } from "./scroll.ts"; import type { ChatMessageCache } from "./session-message-cache.ts"; +import { + updateSidebarSessionActivePanel, + updateSidebarSessionLayout, +} from "./sidebar-layout-persistence.ts"; +import { + SIDEBAR_NARROW_BREAKPOINT_PX, + activatePanel, + closeSlot, + fitSidebarLayout, + normalizeSidebarLayout, + openSlot, +} from "./sidebar-layout.ts"; import { resetToolStream } from "./tool-stream.ts"; type ChatPageElement = { + getBoundingClientRect?: () => DOMRect; querySelector: (selectors: string) => Element | null; }; @@ -81,6 +95,10 @@ export function createPageState( chatMessagesBySession: ChatMessageCache = new Map(), ): ChatPageHost { const settings = loadSettings(); + const sidebarSessionKey = canonicalUiSessionKeyForPersistence( + { agentsList: context.agents.state.agentsList, hello: context.gateway?.snapshot.hello }, + settings.sessionKey, + ); const identity = loadLocalUserIdentity(); const appConfig = context.config.current; const state = { @@ -196,11 +214,12 @@ export function createPageState( chatFollowLocked: false, chatIsProgrammaticScroll: false, chatProgrammaticScrollTarget: 0, - sidebarOpen: false, + sidebarLayout: normalizeSidebarLayout(settings.sidebarSessionLayouts?.[sidebarSessionKey]), sidebarContent: null, + sidebarFocusPanelId: settings.sidebarSessionActivePanels?.[sidebarSessionKey] ?? "", + sidebarFocusVersion: 0, imageLightbox: null, imageLightboxRequestVersion: 0, - splitRatio: settings.splitRatio, toolStreamById: new Map(), toolStreamOrder: [], toolStreamSyncTimer: null, @@ -230,9 +249,7 @@ export function createPageState( chatShowToolCalls: next.chatShowToolCalls, chatPersistCommentary: next.chatPersistCommentary, chatSendShortcut: next.chatSendShortcut, - splitRatio: next.splitRatio, }); - state.splitRatio = state.settings.splitRatio; renderLifecycle.invalidate(); }; state.setChatViewMenuOpen = (open, options) => { @@ -276,15 +293,59 @@ export function createPageState( await steerQueuedChatMessage(state, id); renderLifecycle.invalidate(); }; - state.handleOpenSidebar = (content) => { - state.sidebarContent = content; - state.sidebarOpen = true; + state.updateSidebarLayout = (layout) => { + const normalized = normalizeSidebarLayout(layout); + state.sidebarLayout = normalized; + state.settings = patchSettings({ + sidebarSessionLayouts: updateSidebarSessionLayout( + loadSettings().sidebarSessionLayouts, + canonicalUiSessionKeyForPersistence(state, state.sessionKey), + normalized, + ), + }); renderLifecycle.invalidate(); }; - state.handleCloseSidebar = () => { - state.sidebarOpen = false; + state.updateSidebarActivePanel = (panelId) => { + const normalizedPanelId = panelId.trim(); + if (!normalizedPanelId) { + return; + } + state.sidebarFocusPanelId = normalizedPanelId; + state.sidebarFocusVersion += 1; + state.settings = patchSettings({ + sidebarSessionActivePanels: updateSidebarSessionActivePanel( + loadSettings().sidebarSessionActivePanels, + canonicalUiSessionKeyForPersistence(state, state.sessionKey), + normalizedPanelId, + ), + }); renderLifecycle.invalidate(); }; + state.handleOpenSidebar = (content) => { + let opened = openSlot(state.sidebarLayout, "detail", "right"); + const detailPanel = opened.columns + .flatMap((column) => column.panels) + .find((panel) => panel.slot === "detail"); + if (detailPanel) { + opened = activatePanel(opened, detailPanel.id); + } + const newColumn = opened.columns.find( + (column) => !state.sidebarLayout.columns.some((current) => current.id === column.id), + ); + const availableWidth = page.getBoundingClientRect?.().width ?? 0; + const fitted = + availableWidth > 0 && availableWidth >= SIDEBAR_NARROW_BREAKPOINT_PX + ? (fitSidebarLayout(opened, availableWidth, newColumn?.id) ?? opened) + : opened; + state.sidebarContent = content; + state.updateSidebarLayout(fitted); + if (detailPanel) { + state.updateSidebarActivePanel(detailPanel.id); + } + }; + state.handleCloseSidebar = () => { + state.updateSidebarLayout(closeSlot(state.sidebarLayout, "detail")); + }; state.beginImageOpen = () => { const requestVersion = invalidateImageLightbox(state); renderLifecycle.invalidate(); @@ -310,9 +371,5 @@ export function createPageState( invalidateImageLightbox(state); renderLifecycle.invalidate(); }; - state.handleSplitRatioChange = (ratio) => { - const next = Math.max(0.4, Math.min(0.7, ratio)); - state.applySettings({ ...state.settings, splitRatio: next }); - }; return state; } diff --git a/ui/src/pages/chat/chat-state-route.ts b/ui/src/pages/chat/chat-state-route.ts index 9592cde0c7b4..3b6e1682adf0 100644 --- a/ui/src/pages/chat/chat-state-route.ts +++ b/ui/src/pages/chat/chat-state-route.ts @@ -1,5 +1,5 @@ import { loadLocalAssistantIdentity } from "../../app/assistant-identity.ts"; -import { patchSettings } from "../../app/settings.ts"; +import { loadSettings, patchSettings } from "../../app/settings.ts"; import { isRenderableControlUiAvatarUrl } from "../../lib/avatar.ts"; import type { ChatQueueItem } from "../../lib/chat/chat-types.ts"; import { scopedAgentParamsForSession } from "../../lib/sessions/index.ts"; @@ -7,6 +7,7 @@ import { DEFAULT_MAIN_KEY, areUiSessionKeysEquivalent, buildAgentMainSessionKey, + canonicalUiSessionKeyForPersistence, normalizeAgentId, parseAgentSessionKey, resolveUiDefaultAgentId, @@ -38,6 +39,7 @@ import { readChatSessionSnapshot, type ChatSessionSnapshot, } from "./session-message-cache.ts"; +import { normalizeSidebarLayout } from "./sidebar-layout.ts"; import { clearAuthoritativeTerminal } from "./terminal-message-identity.ts"; let lastChatComposerMemoryFallbackSequence = 0; @@ -241,9 +243,14 @@ export function resetChatStateForRouteSession( saveChatMessagesForSession(state, previousSessionKey); const snapshot = restoreChatMessagesForSession(state, sessionKey); state.sessionKey = sessionKey; - if (state.sidebarContent?.kind === "session-discussion") { - state.sidebarContent = { ...state.sidebarContent, sessionKey }; - } + state.sidebarContent = null; + const sidebarSessionKey = canonicalUiSessionKeyForPersistence(state, sessionKey); + const sidebarSettings = loadSettings(); + state.sidebarLayout = normalizeSidebarLayout( + sidebarSettings.sidebarSessionLayouts?.[sidebarSessionKey], + ); + state.sidebarFocusPanelId = sidebarSettings.sidebarSessionActivePanels?.[sidebarSessionKey] ?? ""; + state.sidebarFocusVersion += 1; invalidateImageLightbox(state); state.selectedChatSessionArchived = state.sessionsResult?.sessions.some( diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts index cfe8bd76b09e..3d52d67a4c6b 100644 --- a/ui/src/pages/chat/chat-state.test.ts +++ b/ui/src/pages/chat/chat-state.test.ts @@ -39,6 +39,7 @@ import { storedChatOutboxScopeKey, } from "./composer-persistence.ts"; import { scheduleControlUiAfterPaint } from "./performance.ts"; +import { openSlot } from "./sidebar-layout.ts"; beforeEach(() => { vi.spyOn(assistantIdentity, "loadLocalAssistantIdentity").mockReturnValue({ @@ -697,6 +698,16 @@ describe("route composer fallback", () => { expect(state.imageLightbox).toBeNull(); }); + it("clears transient detail content on a route switch", () => { + const { state } = createRouteState(""); + state.sidebarContent = { kind: "markdown", content: "First session detail" }; + state.sidebarLayout = openSlot({ columns: [] }, "detail"); + + resetChatStateForRouteSession(state, "agent:main:second"); + + expect(state.sidebarContent).toBeNull(); + }); + it("restores one atomic history snapshot when returning to a session", () => { vi.stubGlobal("sessionStorage", createStorageMock()); const { state } = createRouteState(""); diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 339a5c62f905..6cdbc8470e03 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -436,7 +436,6 @@ function createChatHeaderState( lastActiveSessionKey: "main", theme: "claw", themeMode: "dark", - splitRatio: 0.6, navCollapsed: false, navWidth: 280, sidebarEntries: [], @@ -652,9 +651,6 @@ function createChatProps( error: null, runError: null, sessions: null, - sidebarOpen: false, - sidebarContent: null, - splitRatio: 0.6, canvasPluginSurfaceUrl: null, embedSandboxMode: "scripts", allowExternalEmbedUrls: false, @@ -691,8 +687,6 @@ function createChatProps( onNavigateToAgent: () => undefined, onSessionSelect: () => undefined, onOpenSidebar: () => undefined, - onCloseSidebar: () => undefined, - onSplitRatioChange: () => undefined, onChatScroll: () => undefined, basePath: "", ...overrides, @@ -1659,26 +1653,7 @@ describe("chat composer workbench", () => { expect(container.querySelector('button[aria-label="Thread workspace"]')).toBeNull(); }); - it("stacks the detail sidebar under the thread with a horizontal divider on narrow panes", () => { - const sidebarProps = { - sidebarOpen: true, - sidebarContent: { kind: "markdown", content: "Stacked detail" } as const, - onCloseSidebar: () => undefined, - }; - const wide = renderChatView(sidebarProps); - const wideContainer = wide.querySelector(".chat-split-container"); - expect(wideContainer?.classList.contains("chat-split-container--open")).toBe(true); - expect(wideContainer?.classList.contains("chat-split-container--stacked")).toBe(false); - // Attribute reflection is async; the property binding lands synchronously. - expect(wide.querySelector("resizable-divider")?.orientation).toBe("vertical"); - - const stacked = renderChatView({ ...sidebarProps, sidebarStacked: true }); - const stackedContainer = stacked.querySelector(".chat-split-container"); - expect(stackedContainer?.classList.contains("chat-split-container--stacked")).toBe(true); - expect(stacked.querySelector("resizable-divider")?.orientation).toBe("horizontal"); - }); - - it("opens inline Markdown images and renders the active lightbox", () => { + it("opens inline Markdown images", () => { const onOpenImage = vi.fn(); const src = "data:image/png;base64,cG5n"; const container = renderChatView({ onOpenImage }); @@ -1701,64 +1676,6 @@ describe("chat composer workbench", () => { fallbackTrigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(openSpy).toHaveBeenCalledWith(src, "_blank", "noopener,noreferrer"); openSpy.mockRestore(); - - const onCloseImage = vi.fn(); - const lightboxContainer = renderChatView({ - imageLightbox: { src, title: "Artifact preview" }, - onCloseImage, - }); - const lightbox = lightboxContainer.querySelector("openclaw-image-lightbox"); - expect(lightbox?.src).toBe(src); - expect(lightbox?.title).toBe("Artifact preview"); - lightbox?.dispatchEvent(new CustomEvent("image-lightbox-close", { bubbles: true })); - expect(onCloseImage).toHaveBeenCalledTimes(1); - }); - - it("keeps lightbox Escape from clearing the pending reply", () => { - const onClearReply = vi.fn(); - const container = renderChatView({ - replyTarget: { messageId: "reply-1", text: "Keep this reply" }, - onClearReply, - imageLightbox: { - src: "data:image/png;base64,cG5n", - title: "Artifact preview", - }, - onCloseImage: vi.fn(), - }); - const lightbox = container.querySelector("openclaw-image-lightbox"); - - lightbox?.dispatchEvent( - new KeyboardEvent("keydown", { key: "Escape", bubbles: true, composed: true }), - ); - - expect(onClearReply).not.toHaveBeenCalled(); - }); - - it("opens sidebar Markdown images once", async () => { - const onOpenImage = vi.fn(); - const container = renderChatView({ - sidebarOpen: true, - sidebarContent: { - kind: "markdown", - content: "![Preview](data:image/png;base64,cG5n)", - }, - onCloseSidebar: vi.fn(), - onOpenImage, - }); - document.body.append(container); - const panel = container.querySelector("openclaw-chat-detail-panel") as - | (Element & { updateComplete: Promise }) - | null; - await panel?.updateComplete; - - panel?.querySelector(".markdown-inline-image-button")?.click(); - - expect(onOpenImage).toHaveBeenCalledOnce(); - expect(onOpenImage).toHaveBeenCalledWith({ - src: "data:image/png;base64,cG5n", - title: "Preview", - }); - container.remove(); }); it("forces the workspace rail to the bottom dock and drops side-dock controls on narrow panes", () => { diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 90d568bff61d..4bb19640a839 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -41,25 +41,15 @@ import { type BackgroundTasksProps, } from "./components/chat-background-tasks.ts"; import { isChatRunWorking, renderChatComposer } from "./components/chat-composer.ts"; -import { - inlineChatImageFromEvent, - openInlineChatImage, - renderChatImageLightbox, -} from "./components/chat-image-lightbox.ts"; +import { inlineChatImageFromEvent, openInlineChatImage } from "./components/chat-image-lightbox.ts"; import { renderChatPullRequests } from "./components/chat-pull-requests.ts"; -import { renderChatResizableDivider } from "./components/chat-resizable-divider.ts"; -import "./components/chat-sidebar.ts"; import type { SessionRailMode } from "./components/chat-session-rail.ts"; import { renderChatSessionSuggestions } from "./components/chat-session-suggestions.ts"; import { renderSessionWorkspaceRail, type SessionWorkspaceProps, } from "./components/chat-session-workspace.ts"; -import type { - DetailFullMessageResult, - SidebarContent, - SidebarFullMessageRequest, -} from "./components/chat-sidebar.ts"; +import type { SidebarContent } from "./components/chat-sidebar.ts"; import { renderChatSwarmProgress } from "./components/chat-swarm-progress.ts"; import { renderChatTaskSuggestions } from "./components/chat-task-suggestions.ts"; import { @@ -169,15 +159,6 @@ export type ChatProps = { sessionHost?: UiSessionDefaultsHost | null; providerUsage?: ProviderUsageDisplayProps; focusMode?: boolean; - onLoadSidebarFullMessage?: ( - request: SidebarFullMessageRequest, - ) => Promise; - sidebarOpen?: boolean; - sidebarContent?: SidebarContent | null; - /** Pane too narrow for side-by-side chat + detail panel: stack them - * vertically instead (the divider flips to a horizontal handle). */ - sidebarStacked?: boolean; - splitRatio?: number; canvasPluginSurfaceUrl?: string | null; boardProvider?: BoardProvider; embedSandboxMode?: EmbedSandboxMode; @@ -197,10 +178,8 @@ export type ChatProps = { getAttachments?: () => ChatAttachment[]; onAttachmentsChange?: (attachments: ChatAttachment[]) => void; onAssistantAttachmentLoaded?: () => void; - imageLightbox?: ImageLightboxItem | null; onRequestOpenImage?: () => number; onOpenImage?: (item: ImageLightboxItem, requestVersion?: number) => void; - onCloseImage?: () => void; showNewMessages?: boolean; onScrollToBottom?: (options?: { smooth?: boolean }) => void; onRefresh: () => void; @@ -241,8 +220,6 @@ export type ChatProps = { onOpenSidebar?: (content: SidebarContent) => void; onOpenWorkspaceFile?: (target: { path: string; line?: number | null }) => void; onRevealWorkspaceFile?: (path: string) => void; - onCloseSidebar?: () => void; - onSplitRatioChange?: (ratio: number) => void; onChatScroll?: (event: Event) => void; basePath?: string; gatewayUrl?: string; @@ -287,9 +264,6 @@ function isImageLightboxEvent(event: Event): boolean { export function renderChat(props: ChatProps) { const requestUpdate = props.onRequestUpdate ?? (() => {}); - const splitRatio = props.splitRatio ?? 0.6; - const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar); - const sidebarStacked = props.sidebarStacked === true; const workspaceCollapsed = props.sessionWorkspace?.collapsed !== false; const workspaceDockBottom = Boolean( props.sessionWorkspace && @@ -553,16 +527,11 @@ export function renderChat(props: ChatProps) { ` : nothing}
-
+
${thread} @@ -633,31 +602,9 @@ export function renderChat(props: ChatProps) { ` : nothing}
- - ${sidebarOpen - ? html`${renderChatResizableDivider({ - label: t("nav.resize"), - orientation: sidebarStacked ? "horizontal" : "vertical", - splitRatio, - onResize: (event) => props.onSplitRatioChange?.(event.detail.splitRatio), - })} - props.onCloseSidebar?.()} - > ` - : nothing}
- ${renderChatImageLightbox(props.imageLightbox, props.onCloseImage)} `; } diff --git a/ui/src/pages/chat/components/chat-resizable-divider.ts b/ui/src/pages/chat/components/chat-resizable-divider.ts index 5ccaf5f339a5..d37723321a3f 100644 --- a/ui/src/pages/chat/components/chat-resizable-divider.ts +++ b/ui/src/pages/chat/components/chat-resizable-divider.ts @@ -5,8 +5,12 @@ export function renderChatResizableDivider(props: { className?: string; label: string; maxRatio?: number; + measureRatio?: () => number; + measureSize?: () => number; minRatio?: number; onElement?: (element: Element | undefined) => void; + onDragover?: (event: DragEvent) => void; + onDrop?: (event: DragEvent) => void; onResize: (event: CustomEvent<{ splitRatio: number }>) => void; orientation: "horizontal" | "vertical"; splitRatio: number; @@ -17,8 +21,12 @@ export function renderChatResizableDivider(props: { .splitRatio=${props.splitRatio} .minRatio=${props.minRatio ?? 0.4} .maxRatio=${props.maxRatio ?? 0.7} + .measureRatio=${props.measureRatio} + .measureSize=${props.measureSize} .label=${props.label} .orientation=${props.orientation} + @dragover=${props.onDragover ?? (() => {})} + @drop=${props.onDrop ?? (() => {})} @resize=${props.onResize} >`; } diff --git a/ui/src/pages/chat/components/chat-sidebar-region-types.ts b/ui/src/pages/chat/components/chat-sidebar-region-types.ts new file mode 100644 index 000000000000..344c4683d6ea --- /dev/null +++ b/ui/src/pages/chat/components/chat-sidebar-region-types.ts @@ -0,0 +1,12 @@ +import type { TemplateResult } from "lit"; +import type { SidebarSide, SidebarSlotId } from "../sidebar-layout.ts"; + +export type SidebarPanelTemplates = Partial>; + +export type SidebarRegionCallbacks = { + activatePanel: (panelId: string) => void; + closeSlot: (slot: SidebarSlotId) => void; + detachPanel: (panelId: string, side: SidebarSide, columnIndex: number) => void; + mergePanel: (panelId: string, targetColumnId: string, panelIndex: number) => void; + resizeColumn: (columnId: string, width: number) => void; +}; diff --git a/ui/src/pages/chat/components/chat-sidebar-region.runtime.ts b/ui/src/pages/chat/components/chat-sidebar-region.runtime.ts new file mode 100644 index 000000000000..6c8d91971168 --- /dev/null +++ b/ui/src/pages/chat/components/chat-sidebar-region.runtime.ts @@ -0,0 +1,395 @@ +import { html, nothing, render as renderTemplate } from "lit"; +import { property, state } from "lit/decorators.js"; +import { repeat } from "lit/directives/repeat.js"; +import { styleMap } from "lit/directives/style-map.js"; +import { icons } from "../../../components/icons.ts"; +import "../../../components/web-awesome-tabs.ts"; +import { t } from "../../../i18n/index.ts"; +import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts"; +import { + SIDEBAR_MIN_WIDTH_PX, + isSidebarRegionCollapsed, + type SidebarColumn, + type SidebarLayout, + type SidebarPanel, + type SidebarSide, + type SidebarSlotId, +} from "../sidebar-layout.ts"; +import { resolveSplitDropZone } from "../split-drop-zone.ts"; +import { renderChatResizableDivider } from "./chat-resizable-divider.ts"; +import type { SidebarPanelTemplates, SidebarRegionCallbacks } from "./chat-sidebar-region-types.ts"; +import "./chat-sidebar.ts"; +import "./session-discussion-panel.ts"; + +function panelTitle(slot: SidebarSlotId): string { + if (slot === "chat") { + return t("chat.sidebarColumns.chat"); + } + if (slot === "discussion") { + return t("chat.sidebarColumns.discussion"); + } + return t("chat.sidebarColumns.detail"); +} + +function panelsOf(layout: SidebarLayout): SidebarPanel[] { + return layout.columns.flatMap((column) => column.panels); +} + +class ChatSidebarRegion extends OpenClawLightDomElement { + @property({ attribute: false }) layout: SidebarLayout = { columns: [] }; + @property({ attribute: false }) panelTemplates: SidebarPanelTemplates = {}; + @property({ attribute: false }) panelOpenUrls: Partial> = {}; + @property({ attribute: false }) callbacks: SidebarRegionCallbacks | null = null; + @property() sessionKey = ""; + @property() focusPanelId = ""; + @property({ type: Number }) focusVersion = 0; + @property({ type: Boolean }) narrow = false; + @property({ type: Number }) availableWidth = 0; + + @state() private draggedPanelId = ""; + + private startDrag(event: DragEvent, panelId: string) { + this.draggedPanelId = panelId; + event.dataTransfer?.setData("application/x-openclaw-sidebar-panel", panelId); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "move"; + } + } + + private endDrag() { + this.draggedPanelId = ""; + } + + private draggedPanel(): string { + return this.draggedPanelId; + } + + private allowPanelDrop(event: DragEvent) { + if (!this.draggedPanel()) { + return; + } + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "move"; + } + } + + private dropOnHeader(event: DragEvent, column: SidebarColumn) { + const panelId = this.draggedPanel(); + if (!panelId) { + return; + } + event.preventDefault(); + const tab = event + .composedPath() + .find( + (target): target is HTMLElement => + target instanceof HTMLElement && target.classList.contains("sidebar-column__tab"), + ); + const targetPanelId = tab?.dataset.panelId; + let panelIndex = column.panels.length; + if (targetPanelId && tab) { + const targetIndex = column.panels.findIndex((panel) => panel.id === targetPanelId); + const rect = tab.getBoundingClientRect(); + const zone = resolveSplitDropZone(rect, event.clientX, event.clientY); + panelIndex = targetIndex + (zone.kind === "edge" && zone.edge === "left" ? 0 : 1); + } + this.callbacks?.mergePanel(panelId, column.id, panelIndex); + this.endDrag(); + } + + private dropOnBoundary( + event: DragEvent, + side: SidebarSide, + columnIndex: number, + element: Element | undefined, + ) { + const panelId = this.draggedPanel(); + if (!panelId || !(element instanceof HTMLElement)) { + return; + } + const rect = element.getBoundingClientRect(); + const zone = resolveSplitDropZone(rect, event.clientX, event.clientY); + if (zone.kind !== "edge" || (zone.edge !== "left" && zone.edge !== "right")) { + return; + } + event.preventDefault(); + this.callbacks?.detachPanel(panelId, side, columnIndex); + this.endDrag(); + } + + private activate(panelId: string) { + this.callbacks?.activatePanel(panelId); + } + + private renderHeader(column: SidebarColumn, activePanelId: string, narrow: boolean) { + const active = column.panels.find((panel) => panel.id === activePanelId) ?? column.panels[0]; + if (!active) { + return nothing; + } + const openUrl = this.panelOpenUrls[active.slot]; + return html` + + `; + } + + private renderColumn(column: SidebarColumn) { + const active = + column.panels.find((panel) => panel.id === column.activePanelId) ?? column.panels[0]; + return html` + + `; + } + + private renderPanel(panel: SidebarPanel, collapsed: boolean, activePanelId: string) { + const column = this.layout.columns.find((candidate) => + candidate.panels.some((entry) => entry.id === panel.id), + ); + if (!column) { + return nothing; + } + const sideColumns = this.layout.columns.filter((candidate) => candidate.side === column.side); + const columnIndex = sideColumns.findIndex((candidate) => candidate.id === column.id); + const offsetColumns = + column.side === "left" + ? sideColumns.slice(0, columnIndex) + : sideColumns.slice(columnIndex + 1); + const offset = offsetColumns.reduce((sum, candidate) => sum + candidate.width + 4, 0); + const panelStyle = collapsed + ? {} + : { [column.side]: `${offset}px`, width: `${column.width}px` }; + return html``; + } + + private renderDivider(column: SidebarColumn, side: SidebarSide, columnIndex: number) { + let divider: Element | undefined; + return renderChatResizableDivider({ + className: "sidebar-column__divider", + label: t("chat.sidebarColumns.resize", { + panel: panelTitle(column.panels[0]?.slot ?? "detail"), + }), + orientation: "vertical", + splitRatio: 0.5, + minRatio: 0.05, + maxRatio: 0.95, + measureRatio: () => { + const { previous, next } = this.dividerNeighbors(column, side); + const previousWidth = previous?.width ?? 0; + const total = previousWidth + (next?.width ?? 0); + return total > 0 ? previousWidth / total : 0.5; + }, + measureSize: () => { + const { previous, next } = this.dividerNeighbors(column, side); + return (previous?.width ?? 0) + (next?.width ?? 0); + }, + onElement: (element) => { + divider = element; + if (!(element instanceof HTMLElement)) { + return; + } + queueMicrotask(() => { + const { previous, next } = this.dividerNeighbors(column, side); + const total = (previous?.width ?? 0) + (next?.width ?? 0); + if (total > 0) { + (element as HTMLElement & { splitRatio: number }).splitRatio = + (previous?.width ?? 0) / total; + } + }); + }, + onDragover: (event) => this.allowPanelDrop(event), + onDrop: (event) => this.dropOnBoundary(event, side, columnIndex, divider), + onResize: (event) => { + const { previous, next } = this.dividerNeighbors(column, side); + const total = (previous?.width ?? 0) + (next?.width ?? 0); + if (total <= 0) { + return; + } + const requested = + side === "left" ? total * event.detail.splitRatio : total * (1 - event.detail.splitRatio); + const regionWidth = + this.availableWidth > 0 + ? this.availableWidth + : (this.parentElement?.getBoundingClientRect().width ?? 0); + const maxWidth = Math.max(SIDEBAR_MIN_WIDTH_PX, regionWidth * 0.6); + this.callbacks?.resizeColumn(column.id, Math.min(requested, maxWidth)); + }, + }); + } + + private dividerNeighbors(column: SidebarColumn, side: SidebarSide) { + const sideColumns = this.layout.columns.filter((candidate) => candidate.side === side); + const columnIndex = sideColumns.findIndex((candidate) => candidate.id === column.id); + const columnElements = new Map( + Array.from( + this.parentElement?.querySelectorAll(".sidebar-column[data-column-id]") ?? [], + (element) => [element.dataset.columnId, element], + ), + ); + const primary = this.parentElement?.querySelector(".sidebar-region__primary"); + const previousElement = + side === "left" + ? columnElements.get(column.id) + : columnIndex > 0 + ? columnElements.get(sideColumns[columnIndex - 1]?.id) + : primary; + const nextElement = + side === "left" + ? columnIndex + 1 < sideColumns.length + ? columnElements.get(sideColumns[columnIndex + 1]?.id) + : primary + : columnElements.get(column.id); + return { + previous: previousElement?.getBoundingClientRect(), + next: nextElement?.getBoundingClientRect(), + }; + } + + private renderNarrowColumn(panels: SidebarPanel[], activePanelId: string) { + const collapsed: SidebarColumn = { + id: "collapsed-sidebar-column", + side: "right", + panels, + activePanelId, + width: SIDEBAR_MIN_WIDTH_PX, + }; + return panels.length > 0 + ? html`` + : nothing; + } + + private renderState() { + const width = this.availableWidth > 0 ? this.availableWidth : Number.POSITIVE_INFINITY; + const collapsed = this.narrow || isSidebarRegionCollapsed(this.layout, width); + const panels = panelsOf(this.layout); + const activePanelId = + panels.find((panel) => panel.id === this.focusPanelId)?.id ?? + this.layout.columns.at(-1)?.activePanelId ?? + panels[0]?.id ?? + ""; + return { activePanelId, collapsed, panels }; + } + + private renderRight(collapsed: boolean, panels: SidebarPanel[], activePanelId: string) { + if (collapsed) { + return this.renderNarrowColumn(panels, activePanelId); + } + return this.layout.columns + .filter((column) => column.side === "right") + .map( + (column, index) => html` + ${this.renderDivider(column, "right", index)} ${this.renderColumn(column)} + `, + ); + } + + private renderPanels(collapsed: boolean, panels: SidebarPanel[], activePanelId: string) { + return repeat( + panels, + (panel) => panel.id, + (panel) => this.renderPanel(panel, collapsed, activePanelId), + ); + } + + protected override updated() { + const shell = this.parentElement; + const rightRoot = shell?.querySelector(".sidebar-region__right-runtime"); + const panelsRoot = shell?.querySelector(".sidebar-region__panels-runtime"); + if (!rightRoot || !panelsRoot) { + return; + } + const { activePanelId, collapsed, panels } = this.renderState(); + renderTemplate(this.renderRight(collapsed, panels, activePanelId), rightRoot); + renderTemplate(this.renderPanels(collapsed, panels, activePanelId), panelsRoot); + } + + override render() { + const { collapsed } = this.renderState(); + const left = this.layout.columns.filter((column) => column.side === "left"); + return html`${collapsed + ? nothing + : left.map( + (column, index) => html` + ${this.renderColumn(column)} ${this.renderDivider(column, "left", index + 1)} + `, + )}`; + } +} + +if (!customElements.get("openclaw-chat-sidebar-region")) { + customElements.define("openclaw-chat-sidebar-region", ChatSidebarRegion); +} + +declare global { + interface HTMLElementTagNameMap { + "openclaw-chat-sidebar-region": ChatSidebarRegion; + } +} diff --git a/ui/src/pages/chat/components/chat-sidebar-region.test.ts b/ui/src/pages/chat/components/chat-sidebar-region.test.ts new file mode 100644 index 000000000000..0280584b4a8b --- /dev/null +++ b/ui/src/pages/chat/components/chat-sidebar-region.test.ts @@ -0,0 +1,328 @@ +/* @vitest-environment jsdom */ + +import { html } from "lit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import "../../../components/resizable-divider.ts"; +import { mergePanelIntoColumn, openSlot } from "../sidebar-layout.ts"; +import "./chat-sidebar-region.runtime.ts"; + +type Region = HTMLElementTagNameMap["openclaw-chat-sidebar-region"] & { + updateComplete: Promise; +}; + +const regions: Region[] = []; + +async function createRegion(narrow: boolean) { + const shell = document.createElement("div"); + shell.className = `sidebar-region ${narrow ? "sidebar-region--narrow" : ""}`; + const region = document.createElement("openclaw-chat-sidebar-region") as Region; + region.layout = openSlot(openSlot(openSlot({ columns: [] }, "discussion"), "chat"), "detail"); + region.panelTemplates = { + chat: html`
Chat panel
`, + detail: html`
Detail panel
`, + discussion: html`
Discussion panel
`, + }; + region.callbacks = { + activatePanel: vi.fn(), + closeSlot: vi.fn(), + detachPanel: vi.fn(), + mergePanel: vi.fn(), + resizeColumn: vi.fn(), + }; + region.narrow = narrow; + region.availableWidth = narrow ? 620 : 1_600; + const primary = document.createElement("div"); + primary.className = "sidebar-region__primary"; + primary.innerHTML = "
Primary
"; + const rightRuntime = document.createElement("div"); + rightRuntime.className = "sidebar-region__right-runtime"; + const panelsRuntime = document.createElement("div"); + panelsRuntime.className = "sidebar-region__panels-runtime"; + shell.append(region, primary, rightRuntime, panelsRuntime); + document.body.append(shell); + regions.push(region); + await region.updateComplete; + return region; +} + +function regionRoot(region: Region): HTMLElement { + return region.parentElement!; +} + +afterEach(() => { + for (const region of regions.splice(0)) { + region.parentElement?.remove(); + } +}); + +describe("chat sidebar region", () => { + it("renders independent columns in rank order on wide panes", async () => { + const region = await createRegion(false); + expect(regionRoot(region).querySelectorAll(".sidebar-column")).toHaveLength(3); + expect( + Array.from(regionRoot(region).querySelectorAll(".sidebar-column__tab"), (tab) => + tab.textContent?.trim(), + ), + ).toEqual(["Chat", "Details", "Discussion"]); + expect(regionRoot(region).querySelector("[data-primary]")).not.toBeNull(); + }); + + it("collapses every open panel into one tabbed column on narrow panes", async () => { + const region = await createRegion(true); + expect(regionRoot(region).querySelectorAll(".sidebar-column")).toHaveLength(1); + expect(regionRoot(region).querySelectorAll(".sidebar-column__tab")).toHaveLength(3); + expect( + Array.from( + regionRoot(region).querySelectorAll(".sidebar-column__tab"), + ).every((tab) => !tab.draggable), + ).toBe(true); + + regionRoot(region).querySelectorAll(".sidebar-column__tab")[1]?.click(); + await region.updateComplete; + + expect(regionRoot(region).querySelector('[data-panel="detail"]')).not.toBeNull(); + expect(regionRoot(region).querySelector('[data-panel="chat"]')).not.toBeNull(); + expect(region.callbacks?.activatePanel).toHaveBeenCalled(); + }); + + it("activates a panel opened after the narrow region is already visible", async () => { + const region = await createRegion(true); + region.layout = openSlot({ columns: [] }, "chat"); + await region.updateComplete; + + region.layout = openSlot(region.layout, "discussion"); + await region.updateComplete; + + expect(regionRoot(region).querySelector('[data-panel="discussion"]')).not.toBeNull(); + }); + + it("foregrounds an already-mounted panel from a focus request", async () => { + const region = await createRegion(true); + const discussion = region.layout.columns[2]!.panels[0]!; + + region.focusPanelId = discussion.id; + region.focusVersion += 1; + await region.updateComplete; + + expect( + regionRoot(region).querySelector('[data-panel="discussion"]')?.parentElement?.hidden, + ).toBe(false); + expect(regionRoot(region).querySelector('[data-panel="chat"]')?.parentElement?.hidden).toBe( + true, + ); + }); + + it("routes native header drops through the merge callback", async () => { + const region = await createRegion(false); + const tabs = regionRoot(region).querySelectorAll(".sidebar-column__tab"); + const source = tabs[0]; + const target = tabs[1]; + expect(source).toBeDefined(); + expect(target).toBeDefined(); + const values = new Map(); + const dataTransfer = { + effectAllowed: "none", + dropEffect: "none", + getData: (type: string) => values.get(type) ?? "", + setData: (type: string, value: string) => values.set(type, value), + }; + const start = new Event("dragstart", { bubbles: true }) as DragEvent; + Object.defineProperty(start, "dataTransfer", { value: dataTransfer }); + source!.dispatchEvent(start); + const drop = new Event("drop", { bubbles: true }) as DragEvent; + Object.defineProperties(drop, { + clientX: { value: 1 }, + clientY: { value: 1 }, + dataTransfer: { value: dataTransfer }, + }); + target!.dispatchEvent(drop); + + expect(region.callbacks?.mergePanel).toHaveBeenCalledWith( + region.layout.columns[0]?.panels[0]?.id, + region.layout.columns[1]?.id, + 1, + ); + }); + + it("routes boundary drops through the detach callback", async () => { + const region = await createRegion(false); + const source = regionRoot(region).querySelectorAll(".sidebar-column__tab")[1]!; + const boundary = regionRoot(region).querySelector("resizable-divider")!; + boundary.getBoundingClientRect = () => ({ left: 0, top: 0, width: 4, height: 100 }) as DOMRect; + const values = new Map(); + const dataTransfer = { + effectAllowed: "none", + dropEffect: "none", + getData: (type: string) => values.get(type) ?? "", + setData: (type: string, value: string) => values.set(type, value), + }; + const start = new Event("dragstart", { bubbles: true }) as DragEvent; + Object.defineProperty(start, "dataTransfer", { value: dataTransfer }); + source.dispatchEvent(start); + const drop = new Event("drop", { bubbles: true }) as DragEvent; + Object.defineProperties(drop, { + clientX: { value: 1 }, + clientY: { value: 50 }, + dataTransfer: { value: dataTransfer }, + }); + boundary.dispatchEvent(drop); + + expect(region.callbacks?.detachPanel).toHaveBeenCalledWith( + region.layout.columns[1]?.panels[0]?.id, + "right", + 0, + ); + }); + + it("resizes the primary-adjacent column across the light-DOM shell boundary", async () => { + const region = await createRegion(false); + const primary = regionRoot(region).querySelector(".sidebar-region__primary"); + const divider = regionRoot(region).querySelector( + ".sidebar-region__right-runtime .sidebar-column__divider", + ); + const column = region.layout.columns.find((candidate) => candidate.side === "right"); + expect(primary).toBeDefined(); + expect(divider).toBeDefined(); + expect(column).toBeDefined(); + primary!.getBoundingClientRect = () => ({ width: 800 }) as DOMRect; + regionRoot(region).querySelector( + `[data-column-id="${column!.id}"]`, + )!.getBoundingClientRect = () => ({ width: 320 }) as DOMRect; + divider!.setPointerCapture = vi.fn(); + divider!.releasePointerCapture = vi.fn(); + divider!.hasPointerCapture = vi.fn(() => true); + const pointerDown = new MouseEvent("pointerdown", { + bubbles: true, + button: 0, + clientX: 500, + }); + Object.defineProperty(pointerDown, "pointerId", { value: 7 }); + divider!.dispatchEvent(pointerDown); + document.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX: 612 })); + document.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 612 })); + + const resizedWidth = vi.mocked(region.callbacks!.resizeColumn).mock.lastCall?.[1]; + expect(region.callbacks?.resizeColumn).toHaveBeenCalledWith(column!.id, expect.any(Number)); + expect(resizedWidth).toBeCloseTo(208); + + vi.mocked(region.callbacks!.resizeColumn).mockClear(); + divider!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowRight" })); + const keyboardWidth = vi.mocked(region.callbacks!.resizeColumn).mock.lastCall?.[1]; + expect(keyboardWidth).toBeCloseTo(297.6); + }); + + it("ignores a drag payload started by another sidebar region", async () => { + const sourceRegion = await createRegion(false); + const targetRegion = await createRegion(false); + const source = regionRoot(sourceRegion).querySelector(".sidebar-column__tab")!; + const target = regionRoot(targetRegion).querySelector(".sidebar-column__tab")!; + const values = new Map(); + const dataTransfer = { + effectAllowed: "none", + dropEffect: "none", + getData: (type: string) => values.get(type) ?? "", + setData: (type: string, value: string) => values.set(type, value), + }; + const start = new Event("dragstart", { bubbles: true }) as DragEvent; + Object.defineProperty(start, "dataTransfer", { value: dataTransfer }); + source.dispatchEvent(start); + const drop = new Event("drop", { bubbles: true }) as DragEvent; + Object.defineProperties(drop, { + clientX: { value: 1 }, + clientY: { value: 1 }, + dataTransfer: { value: dataTransfer }, + }); + target.dispatchEvent(drop); + + expect(targetRegion.callbacks?.mergePanel).not.toHaveBeenCalled(); + }); + + it("preserves a panel DOM node when it moves between columns", async () => { + const region = await createRegion(false); + const detail = region.layout.columns[1]!.panels[0]!; + const target = region.layout.columns[2]!; + const detailNode = regionRoot(region).querySelector('[data-panel="detail"]'); + + region.layout = mergePanelIntoColumn(region.layout, detail.id, target.id, 0); + await region.updateComplete; + + expect(regionRoot(region).querySelector('[data-panel="detail"]')).toBe(detailNode); + }); + + it("preserves a panel DOM node while crossing the responsive breakpoint", async () => { + const region = await createRegion(false); + const detailNode = regionRoot(region).querySelector('[data-panel="detail"]'); + + region.narrow = true; + region.availableWidth = 620; + region.parentElement?.classList.add("sidebar-region--narrow"); + await region.updateComplete; + + expect(regionRoot(region).querySelector('[data-panel="detail"]')).toBe(detailNode); + }); + + it("preserves the primary DOM node while crossing the responsive breakpoint", async () => { + const region = await createRegion(false); + const primaryNode = regionRoot(region).querySelector("[data-primary]"); + + region.narrow = true; + region.availableWidth = 620; + await region.updateComplete; + + expect(regionRoot(region).querySelector("[data-primary]")).toBe(primaryNode); + }); + + it("restores the persisted active tab when the session changes", async () => { + const region = await createRegion(true); + const chat = region.layout.columns[0]!.panels[0]!; + const discussion = region.layout.columns[2]!.panels[0]!; + let merged = mergePanelIntoColumn( + region.layout, + discussion.id, + region.layout.columns[0]!.id, + 1, + ); + const detail = merged.columns[1]!.panels[0]!; + merged = mergePanelIntoColumn(merged, detail.id, merged.columns[0]!.id, 1); + merged.columns[0]!.activePanelId = chat.id; + region.layout = merged; + region.sessionKey = "session-a"; + await region.updateComplete; + merged = { ...merged, columns: merged.columns.map((column) => ({ ...column })) }; + merged.columns[0]!.activePanelId = discussion.id; + + region.layout = merged; + region.sessionKey = "session-b"; + await region.updateComplete; + + expect( + regionRoot(region).querySelector('[data-panel="discussion"]')?.parentElement?.hidden, + ).toBe(false); + }); + + it("gives a simultaneous explicit focus request precedence on session change", async () => { + const region = await createRegion(true); + const detail = region.layout.columns[1]!.panels[0]!; + + region.sessionKey = "session-b"; + region.focusPanelId = detail.id; + region.focusVersion += 1; + await region.updateComplete; + + expect(regionRoot(region).querySelector('[data-panel="detail"]')?.parentElement?.hidden).toBe( + false, + ); + }); + + it("keeps the narrow grid off when no panel is open", async () => { + const region = await createRegion(true); + region.layout = { columns: [] }; + region.parentElement?.classList.remove("sidebar-region--narrow"); + await region.updateComplete; + + // The two-row narrow grid must not reserve a panel row for an empty layout, + // or every default mobile chat pane loses half its height. + expect(region.parentElement?.classList.contains("sidebar-region--narrow")).toBe(false); + expect(regionRoot(region).querySelector("[data-primary]")).not.toBeNull(); + }); +}); diff --git a/ui/src/pages/chat/components/chat-sidebar.ts b/ui/src/pages/chat/components/chat-sidebar.ts index b6bd29f04c49..1cb0d25298a9 100644 --- a/ui/src/pages/chat/components/chat-sidebar.ts +++ b/ui/src/pages/chat/components/chat-sidebar.ts @@ -20,18 +20,10 @@ import { copyToClipboard } from "../../../lib/clipboard.ts"; import { type EditorId, openEditor } from "../../../lib/editor-links.ts"; import { openExternalUrlSafe } from "../../../lib/open-external-url.ts"; import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts"; -import "./session-discussion-panel.ts"; import "./session-diff-panel.ts"; import { renderChatSidebarEditorMenu } from "./chat-sidebar-editor-menu.ts"; import type { FileEditorViewHandle } from "./file-editor-view.ts"; import type { SessionDiffLoader } from "./session-diff-panel.ts"; -import type { - SessionDiscussionInfoLoader, - SessionDiscussionOpener, - SessionDiscussionStateListener, -} from "./session-discussion-panel.ts"; - -export const CHAT_DETAIL_FULL_MESSAGE_MAX_CHARS = 500_000; type DetailUnavailableReason = "not_found" | "oversized" | "not_visible"; export type DetailFullMessageResult = { @@ -87,19 +79,6 @@ type SessionDiffSidebarContent = { unavailableReason?: DetailUnavailableReason | null; }; -type SessionDiscussionSidebarContent = { - kind: "session-discussion"; - sessionKey: string; - canOpen: boolean; - openUrl?: string | null; - loadInfo: SessionDiscussionInfoLoader; - openDiscussion: SessionDiscussionOpener; - onStateChange: SessionDiscussionStateListener; - rawText?: string | null; - fullMessageRequest?: SidebarFullMessageRequest; - unavailableReason?: DetailUnavailableReason | null; -}; - type FileSaveOutcome = | { ok: true; hash: string; updatedAtMs?: number } | { ok: false; code: "conflict"; currentHash?: string } @@ -153,7 +132,6 @@ export type SidebarContent = | CanvasSidebarContent | ImageSidebarContent | FileSidebarContent - | SessionDiscussionSidebarContent | SessionDiffSidebarContent; function hasFullMessageRequest(content: SidebarContent): content is SidebarContent & { @@ -517,6 +495,7 @@ type MarkdownSidebarProps = { canvasPluginSurfaceUrl?: string | null; embedSandboxMode?: EmbedSandboxMode; allowExternalEmbedUrls?: boolean; + embedded?: boolean; }; function renderMarkdownSidebar(props: MarkdownSidebarProps) { @@ -540,8 +519,6 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) { props.allowExternalEmbedUrls ?? false, ) : null; - const discussionOpenUrl = - content?.kind === "session-discussion" ? (content.openUrl ?? null) : null; const title = content?.kind === "canvas" ? content.title?.trim() || "Render Preview" @@ -551,48 +528,29 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) { ? content.name.trim() || "File" : content?.kind === "session-diff" ? t("chat.sessionDiff.title") - : content?.kind === "session-discussion" - ? t("chat.sessionDiscussion.title") - : content?.kind === "markdown" - ? "Markdown Preview" - : "Tool Details"; + : content?.kind === "markdown" + ? "Markdown Preview" + : "Tool Details"; return html`