From dadccb4e41c1ec35693ddc15b4dc6f087c97df78 Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Wed, 12 Aug 2026 02:10:38 -0300 Subject: [PATCH] refactor(ui): make chat rails resizable columns --- ui/src/app/settings.ts | 9 - ui/src/components/dock-layout-controller.ts | 23 +- ui/src/components/dock-panel-layout.test.ts | 60 +++ ui/src/e2e/chat-rail-columns.e2e.test.ts | 360 ++++++++++++++++++ ui/src/pages/chat/chat-pane-base.ts | 86 +++++ ui/src/pages/chat/chat-pane-render.ts | 79 ++-- ui/src/pages/chat/chat-pane-shared.ts | 56 ++- ui/src/pages/chat/chat-view.test.ts | 26 +- ui/src/pages/chat/chat-view.ts | 156 ++++---- .../chat/components/chat-image-lightbox.ts | 8 + .../chat/components/chat-session-workspace.ts | 146 +------ ui/src/styles/chat/sidebar.css | 165 ++++---- ui/src/styles/chat/split-view.css | 15 +- 13 files changed, 819 insertions(+), 370 deletions(-) create mode 100644 ui/src/e2e/chat-rail-columns.e2e.test.ts diff --git a/ui/src/app/settings.ts b/ui/src/app/settings.ts index de79f681bc05..2eec34cea014 100644 --- a/ui/src/app/settings.ts +++ b/ui/src/app/settings.ts @@ -147,11 +147,6 @@ export type CatalogOpenTarget = (typeof CATALOG_OPEN_TARGETS)[number]; export const normalizeCatalogOpenTarget = normalizeChoice(CATALOG_OPEN_TARGETS, "viewer"); -const CHAT_WORKSPACE_DOCKS = ["right", "bottom"] as const; -export type ChatWorkspaceDock = (typeof CHAT_WORKSPACE_DOCKS)[number]; - -export const normalizeChatWorkspaceDock = normalizeChoice(CHAT_WORKSPACE_DOCKS, "right"); - export function normalizeTextScale(value: unknown, fallback: TextScaleStop = 100): TextScaleStop { if (typeof value !== "number" || !Number.isFinite(value)) { return fallback; @@ -201,7 +196,6 @@ export type UiSettings = { // Camera intent is device-local, not per-agent or synced through config ui.prefs. talkCameraAutoEnable?: boolean; chatSplitLayout?: ChatSplitLayout; - chatWorkspaceDock?: ChatWorkspaceDock; // Session workspace rail dock edge (default "right") boardSessionViews?: BoardSessionViews; // Per-device active dashboard tab and dock state sidebarSessionLayouts?: SidebarSessionLayouts; // Sidebar columns and widths per session sidebarSessionActivePanels?: SidebarSessionActivePanels; // Collapsed active panel per session @@ -519,7 +513,6 @@ export function loadSettings(): UiSettings { talkCameraAutoEnable: typeof parsed.talkCameraAutoEnable === "boolean" ? parsed.talkCameraAutoEnable : undefined, chatSplitLayout: normalizeChatSplitLayout(parsed.chatSplitLayout), - chatWorkspaceDock: normalizeChatWorkspaceDock(parsed.chatWorkspaceDock), boardSessionViews: normalizeBoardSessionViews(parsed.boardSessionViews), sidebarSessionLayouts: normalizeSidebarSessionLayouts(parsed.sidebarSessionLayouts), sidebarSessionActivePanels: normalizeSidebarSessionActivePanels( @@ -664,8 +657,6 @@ function persistSettings(next: UiSettings, options: { selectGateway?: boolean } ? { talkCameraAutoEnable: next.talkCameraAutoEnable } : {}), ...(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) } : {}), diff --git a/ui/src/components/dock-layout-controller.ts b/ui/src/components/dock-layout-controller.ts index f336fa2dd4a9..7c6a708da1e7 100644 --- a/ui/src/components/dock-layout-controller.ts +++ b/ui/src/components/dock-layout-controller.ts @@ -15,6 +15,8 @@ type DockLayoutControllerOptions = { reservationPrefix: string; isAvailable: () => boolean; isFullscreen?: () => boolean; + maxWidth?: () => number; + reserveViewport?: boolean; onResize?: () => void; }; @@ -28,7 +30,7 @@ export class DockLayoutController implements R private resizeCleanup: (() => void) | null = null; private readonly onViewportResize = () => { const height = Math.min(this.height, this.options.layout.maxHeight()); - const width = Math.min(this.width, this.options.layout.maxWidth()); + const width = Math.min(this.width, this.maxWidth()); if (height === this.height && width === this.width) { return; } @@ -58,7 +60,7 @@ export class DockLayoutController implements R this.open = layout.open && this.options.isAvailable(); this.dock = layout.dock; this.height = layout.height; - this.width = layout.width; + this.width = Math.min(layout.width, this.maxWidth()); window.addEventListener("resize", this.onViewportResize); } @@ -138,7 +140,7 @@ export class DockLayoutController implements R } syncReservation(): void { - if (this.isFullscreen()) { + if (this.isFullscreen() || this.options.reserveViewport === false) { return; } const visible = this.options.isAvailable() && this.open; @@ -166,7 +168,7 @@ export class DockLayoutController implements R this.height = Math.min(next, this.options.layout.maxHeight()); } else { const next = Math.max(this.options.layout.minWidth, startWidth + (startX - move.clientX)); - this.width = Math.min(next, this.options.layout.maxWidth()); + this.width = Math.min(next, this.maxWidth()); } this.syncReservation(); this.options.onResize?.(); @@ -212,6 +214,9 @@ export class DockLayoutController implements R } private clearReservation(): void { + if (this.options.reserveViewport === false) { + return; + } const root = document.documentElement.style; root.setProperty(`--oc-${this.options.reservationPrefix}-reserve-bottom`, "0px"); root.setProperty(`--oc-${this.options.reservationPrefix}-reserve-right`, "0px"); @@ -220,6 +225,16 @@ export class DockLayoutController implements R private isFullscreen(): boolean { return this.options.isFullscreen?.() === true; } + + private maxWidth(): number { + return Math.max( + this.options.layout.minWidth, + Math.min( + this.options.layout.maxWidth(), + this.options.maxWidth?.() ?? Number.POSITIVE_INFINITY, + ), + ); + } } export const dockPanelStyles = css` diff --git a/ui/src/components/dock-panel-layout.test.ts b/ui/src/components/dock-panel-layout.test.ts index 996c015fb35b..ada813db8cad 100644 --- a/ui/src/components/dock-panel-layout.test.ts +++ b/ui/src/components/dock-panel-layout.test.ts @@ -1,9 +1,21 @@ /* @vitest-environment jsdom */ +import type { ReactiveController } from "lit"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createStorageMock } from "../test-helpers/storage.ts"; +import { DockLayoutController } from "./dock-layout-controller.ts"; import { createDockPanelLayout, type DockPanelSide } from "./dock-panel-layout.ts"; +function createControllerHost() { + return { + addController: vi.fn((_controller: ReactiveController) => undefined), + removeController: vi.fn((_controller: ReactiveController) => undefined), + requestUpdate: vi.fn(), + updateComplete: Promise.resolve(true), + isConnected: true, + }; +} + function createLayout(defaultDock: DockPanelSide) { return createDockPanelLayout({ storageKey: `test.dock-panel.${defaultDock}`, @@ -103,3 +115,51 @@ describe("createDockPanelLayout", () => { expect(layout.load()).toEqual({ open: true, dock: "main", height: 320, width: 520 }); }); }); + +describe("DockLayoutController inline columns", () => { + it("resizes and restores a width without reserving the global viewport", () => { + const layout = createDockPanelLayout({ + storageKey: "test.dock-panel.inline", + minHeight: 140, + minWidth: 260, + defaultDock: "right", + supportedDocks: ["right"], + defaultHeight: 320, + defaultWidth: 280, + }); + const reservation = "--oc-test-inline-reserve-right"; + document.documentElement.style.setProperty(reservation, "17px"); + const host = createControllerHost(); + const controller = new DockLayoutController(host, { + layout, + reservationPrefix: "test-inline", + isAvailable: () => true, + maxWidth: () => 420, + reserveViewport: false, + }); + + controller.hostConnected(); + controller.startResize(new MouseEvent("pointerdown", { clientX: 600 }) as PointerEvent); + window.dispatchEvent(new MouseEvent("pointermove", { clientX: 500 })); + window.dispatchEvent(new MouseEvent("pointerup")); + + expect(controller.width).toBe(380); + expect(JSON.parse(localStorage.getItem("test.dock-panel.inline") ?? "{}")).toMatchObject({ + width: 380, + }); + expect(document.documentElement.style.getPropertyValue(reservation)).toBe("17px"); + + const restored = new DockLayoutController(createControllerHost(), { + layout, + reservationPrefix: "test-inline", + isAvailable: () => true, + maxWidth: () => 420, + reserveViewport: false, + }); + restored.hostConnected(); + expect(restored.width).toBe(380); + restored.hostDisconnected(); + controller.hostDisconnected(); + document.documentElement.style.removeProperty(reservation); + }); +}); diff --git a/ui/src/e2e/chat-rail-columns.e2e.test.ts b/ui/src/e2e/chat-rail-columns.e2e.test.ts new file mode 100644 index 000000000000..c6c650a8f122 --- /dev/null +++ b/ui/src/e2e/chat-rail-columns.e2e.test.ts @@ -0,0 +1,360 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import type { Locator, Page } from "playwright"; +import { expect, it } from "vitest"; +import { + controlUiBundledSettingsStorageKey, + installMockGateway, + type ControlUiMockGatewayScenario, +} from "../test-helpers/control-ui-e2e.ts"; +import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; + +const suite = createControlUiE2eSuite({ + name: "chat rail columns", + startServerBeforeBrowser: true, +}); + +const sessionKey = "agent:main:rail-columns"; +const proofDir = process.env.OPENCLAW_UI_RAIL_PROOF_DIR?.trim(); +const theme = process.env.OPENCLAW_UI_RAIL_PROOF_THEME === "dark" ? "dark" : "light"; +const proofPhase = process.env.OPENCLAW_UI_RAIL_PROOF_PHASE === "before" ? "before" : "after"; + +const historyMessages = Array.from({ length: 8 }, (_, index) => ({ + id: `rail-proof-${index}`, + role: index % 2 === 0 ? "user" : "assistant", + content: [ + { + type: "text", + text: + index % 2 === 0 + ? `Review rail layout checkpoint ${index + 1}. Keep the transcript readable while supporting the work beside it.` + : `Checkpoint ${index + 1} is ready. The column should start above this message and keep its own chrome, width, and scroll surface.`, + }, + ], + timestamp: Date.now() - (8 - index) * 60_000, +})); + +function railScenario(): ControlUiMockGatewayScenario { + return { + featureMethods: [ + "chat.metadata", + "chat.startup", + "board.get", + "session.discussion.info", + "session.discussion.open", + "sessions.diff", + "tasks.list", + "terminal.open", + ], + historyMessages, + methodResponses: { + "artifacts.list": { artifacts: [] }, + "board.get": { + sessionKey, + revision: 1, + tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }], + widgets: [ + { + name: "release-status", + tabId: "main", + title: "Release status", + contentKind: "html", + sizeW: 6, + sizeH: 4, + position: 0, + grantState: "pending", + revision: 1, + frameUrl: "about:blank#release-status", + }, + ], + }, + "session.discussion.info": { + embedUrl: "https://discussion.example/embed/channel/T1/C1?openclawHostTheme=1", + openUrl: "https://discussion.example/session", + state: "open", + }, + "session.discussion.open": { + embedUrl: "https://discussion.example/embed/channel/T1/C1?openclawHostTheme=1", + openUrl: "https://discussion.example/session", + state: "open", + }, + "sessions.diff": { + sessionKey, + root: "/workspace/openclaw", + branch: "feature/full-height-rails", + baseRef: "main", + files: [ + { + path: "ui/src/pages/chat/chat-view.ts", + status: "modified", + additions: 3, + deletions: 1, + patch: [ + "diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts", + "--- a/ui/src/pages/chat/chat-view.ts", + "+++ b/ui/src/pages/chat/chat-view.ts", + "@@ -1,2 +1,4 @@", + " existing line", + "+full-height rail", + "+persisted width", + "+shared resize handle", + "", + ].join("\n"), + }, + ], + additions: 3, + deletions: 1, + }, + "sessions.files.list": { + browser: { + path: "ui/src/pages/chat", + entries: [ + { kind: "file", name: "chat-view.ts", path: "ui/src/pages/chat/chat-view.ts" }, + { + kind: "file", + name: "chat-pane-render.ts", + path: "ui/src/pages/chat/chat-pane-render.ts", + }, + ], + }, + files: [ + { + kind: "modified", + missing: false, + name: "chat-view.ts", + path: "/workspace/openclaw/ui/src/pages/chat/chat-view.ts", + size: 15_432, + }, + { + kind: "read", + missing: false, + name: "sidebar.css", + path: "/workspace/openclaw/ui/src/styles/chat/sidebar.css", + size: 22_840, + }, + ], + root: "/workspace/openclaw", + sessionKey, + }, + "tasks.list": { + tasks: [ + { + agentId: "main", + createdAt: Date.now() - 240_000, + id: "task-layout", + kind: "subagent", + ownerKey: sessionKey, + sessionKey, + progressSummary: "Comparing column geometry in both themes", + runtime: "subagent", + startedAt: Date.now() - 210_000, + status: "running", + taskId: "task-layout", + title: "Verify rail layout", + updatedAt: Date.now(), + }, + { + agentId: "main", + createdAt: Date.now() - 420_000, + id: "task-history", + kind: "subagent", + ownerKey: sessionKey, + sessionKey, + progressSummary: "Mapped the terminal and rail layout history", + runtime: "subagent", + startedAt: Date.now() - 410_000, + status: "completed", + taskId: "task-history", + title: "Inspect layout history", + updatedAt: Date.now() - 120_000, + }, + ], + }, + "terminal.list": { sessions: [] }, + "terminal.open": { + agentId: "main", + confined: false, + cwd: "/workspace/openclaw", + sessionId: "rail-proof-terminal", + shell: "/bin/zsh", + }, + }, + sessionKey, + terminalEnabled: true, + workspace: "/workspace/openclaw", + workspaceGit: true, + }; +} + +async function seedTheme(page: Page): Promise { + const settingsKey = controlUiBundledSettingsStorageKey(suite.server.baseUrl); + await page.addInitScript( + ({ key, mode }) => { + localStorage.setItem( + key, + JSON.stringify({ + theme: "claw", + themeMode: mode, + boardSessionViews: { + "agent:main:rail-columns": { activeTabId: "main", face: "dashboard" }, + }, + }), + ); + }, + { key: settingsKey, mode: theme }, + ); +} + +async function expectFullHeightRail(page: Page, rail: Locator): Promise { + const geometry = await page.locator(".chat-split-view__cell").evaluate( + (cell, railElement) => { + if (!(railElement instanceof HTMLElement)) { + throw new Error("Rail element is missing"); + } + const cellRect = cell.getBoundingClientRect(); + const railRect = railElement.getBoundingClientRect(); + const headerRect = cell + .querySelector(".chat-pane__header") + ?.getBoundingClientRect(); + return { + cellTop: cellRect.top, + headerBottom: headerRect?.bottom ?? 0, + railTop: railRect.top, + }; + }, + await rail.elementHandle(), + ); + expect( + Math.abs(geometry.railTop - geometry.cellTop), + JSON.stringify(geometry), + ).toBeLessThanOrEqual(1); + expect(geometry.railTop, JSON.stringify(geometry)).toBeLessThan(geometry.headerBottom - 10); +} + +async function capture(page: Page, name: string): Promise { + if (!proofDir) { + return; + } + await mkdir(proofDir, { recursive: true }); + const baseName = `${proofPhase}-${name}-${theme}`; + await page.screenshot({ path: path.join(proofDir, `${baseName}-context.png`), fullPage: true }); + await page + .locator(".chat-split-view__cell") + .screenshot({ path: path.join(proofDir, `${baseName}-crop.png`) }); +} + +suite.define(() => { + it("keeps every chat rail full-height and restores resized inline widths", async () => { + await suite.withPage( + { + colorScheme: theme, + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1600 }, + }, + async ({ page }) => { + await seedTheme(page); + await page.route("https://discussion.example/embed/channel/**", (route) => + route.fulfill({ + contentType: "text/html; charset=utf-8", + body: ` +

Session discussion

Reviewing the rail layout with the team.

+ `, + }), + ); + const gateway = await installMockGateway(page, railScenario()); + await page.goto(`${suite.server.baseUrl}chat`); + await page.locator(".chat-group").first().waitFor(); + + await page.getByRole("button", { name: "Show background tasks" }).click(); + await page.getByRole("button", { name: "Show session files", exact: true }).click(); + const tasks = page.locator(".chat-tasks-rail"); + const workspace = page.locator(".chat-workspace-rail"); + await tasks.waitFor(); + await workspace.waitFor(); + await expect.poll(() => tasks.textContent()).toContain("Verify rail layout"); + if (proofPhase === "after") { + await expectFullHeightRail(page, tasks); + await expectFullHeightRail(page, workspace); + } + await capture(page, "01-workspace-tasks-default"); + + if (proofPhase === "after") { + const workspaceWidth = await workspace.evaluate( + (element) => element.getBoundingClientRect().width, + ); + const workspaceHandle = page.locator(".chat-workspace-rail-resizer"); + const handleBox = await workspaceHandle.boundingBox(); + expect(handleBox).not.toBeNull(); + await page.mouse.move(handleBox!.x + 2, handleBox!.y + handleBox!.height / 2); + await page.mouse.down(); + await page.mouse.move(handleBox!.x - 84, handleBox!.y + handleBox!.height / 2); + await page.mouse.up(); + await expect + .poll(() => workspace.evaluate((element) => element.getBoundingClientRect().width)) + .toBeGreaterThan(workspaceWidth + 70); + const resizedWidth = await workspace.evaluate( + (element) => element.getBoundingClientRect().width, + ); + await capture(page, "02-workspace-tasks-resized"); + + await page.locator(".chat-workspace-rail__collapse-toggle").click(); + await page.getByRole("button", { name: "Show session files", exact: true }).click(); + await expect + .poll(() => workspace.evaluate((element) => element.getBoundingClientRect().width)) + .toBeCloseTo(resizedWidth, 0); + await capture(page, "03-workspace-tasks-reopened"); + } + + await page.locator(".chat-workspace-rail__collapse-toggle").click(); + await page.locator(".chat-tasks-rail__collapse-toggle").click(); + await page.locator(".chat-session-diff-toggle").click(); + const changes = page.locator('.sidebar-column[data-column-id="detail-column"]'); + await changes.waitFor(); + if (proofPhase === "after") { + await expectFullHeightRail(page, changes); + } + await capture(page, "04-changes"); + + await page.getByRole("button", { name: "Show discussion" }).click(); + const discussion = page.locator('.sidebar-column[data-column-id="discussion-column"]'); + await discussion.waitFor(); + await expect.poll(() => page.locator("iframe.session-discussion__frame").count()).toBe(1); + if (proofPhase === "after") { + await expectFullHeightRail(page, discussion); + } + await capture(page, "05-discussion-clickclack"); + + await page.getByRole("button", { name: "Show session companion" }).click(); + const companion = page.locator("openclaw-chat-session-rail"); + await companion.locator(".chat-session-rail--expanded").waitFor(); + if (proofPhase === "after") { + await expectFullHeightRail(page, companion); + } + await capture(page, "06-companion"); + + await page.evaluate(() => + window.dispatchEvent( + new CustomEvent("openclaw:terminal-toggle", { detail: { open: true } }), + ), + ); + await gateway.waitForRequest("terminal.open"); + await page.locator("openclaw-terminal-panel .tp").waitFor(); + await capture(page, "07-terminal-reference"); + + await page.evaluate(() => + window.dispatchEvent( + new CustomEvent("openclaw:terminal-toggle", { detail: { open: false } }), + ), + ); + await page.goto(`${suite.server.baseUrl}dashboard`); + const boardChat = page.locator('.sidebar-column[data-column-id="chat-column"]'); + await boardChat.waitFor(); + if (proofPhase === "after") { + await expectFullHeightRail(page, boardChat); + } + await capture(page, "08-board-chat"); + }, + ); + }); +}); diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index 46b85af406d1..7a33903b9130 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -23,6 +23,8 @@ import { type QuestionPrompt, } from "../../app/question-prompt.ts"; import type { PresencePayload } from "../../app/user-profile.ts"; +import { DockLayoutController } from "../../components/dock-layout-controller.ts"; +import { t } from "../../i18n/index.ts"; import type { BoardCommandEvent, BoardProvider, @@ -42,8 +44,12 @@ import type { ChatHistoryPagination } from "./chat-history-pagination.ts"; import { sendSessionObserverVisibility } from "./chat-observer.ts"; import { boardChatDockLayout, + chatCompanionRailLayout, + chatTasksRailLayout, + chatWorkspaceRailLayout, type ChatPageContext, type PaneSessionChangeOptions, + sidebarChatLayoutWidth, } from "./chat-pane-shared.ts"; import { SessionParticipationTracker } from "./chat-pane-state.ts"; import { @@ -60,6 +66,14 @@ import type { ChatSessionSharingState } from "./components/chat-session-sharing. import { ChatTranscriptController } from "./components/chat-transcript-controller.ts"; import type { SessionDiscussionPanelConfig } from "./components/session-discussion-panel.ts"; import type { ChatMessageCache } from "./session-message-cache.ts"; +import { + isSidebarRegionCollapsed, + sidebarPrimaryWidth, + type SidebarLayout, +} from "./sidebar-layout.ts"; + +const CHAT_RAIL_MAIN_MIN_WIDTH_PX = 312; +const CHAT_RAIL_DIVIDERS_MAX_WIDTH_PX = 8; export abstract class ChatPaneBase extends OpenClawLightDomElement { // Relative labels still need a minute tick; external PR state is server-pushed. @@ -172,6 +186,78 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { @litState() protected resetConfirmationOpen = false; @litState() protected sessionRailReady = customElements.get("openclaw-chat-session-rail") != null; @litState() protected sessionRailMode: SessionRailMode = "hidden"; + protected readonly companionRailLayout = new DockLayoutController(this, { + layout: chatCompanionRailLayout, + reservationPrefix: "chat-companion-rail", + isAvailable: () => true, + maxWidth: () => this.chatRailMaxWidth(".chat-main"), + reserveViewport: false, + }); + protected readonly tasksRailLayout = new DockLayoutController(this, { + layout: chatTasksRailLayout, + reservationPrefix: "chat-tasks-rail", + isAvailable: () => true, + maxWidth: () => this.chatRailMaxWidth(".chat-workbench", ".chat-workspace-rail"), + reserveViewport: false, + }); + protected readonly workspaceRailLayout = new DockLayoutController(this, { + layout: chatWorkspaceRailLayout, + reservationPrefix: "chat-workspace-rail", + isAvailable: () => true, + maxWidth: () => this.chatRailMaxWidth(".chat-workbench", ".chat-tasks-rail"), + reserveViewport: false, + }); + + protected companionRailColumn() { + return [ + this.companionRailLayout.width, + this.companionRailLayout.renderResizer( + "chat-companion-rail", + t("chat.sidebarColumns.resize", { panel: t("chat.rail.title") }), + ), + ] as const; + } + + protected tasksRailColumn() { + return [ + this.tasksRailLayout.width, + this.tasksRailLayout.renderResizer( + "chat-tasks-rail", + t("chat.sidebarColumns.resize", { panel: t("chat.backgroundTasks.title") }), + ), + ] as const; + } + + protected workspaceRailColumn() { + return [ + this.workspaceRailLayout.width, + this.workspaceRailLayout.renderResizer( + "chat-workspace-rail", + t("chat.sidebarColumns.resize", { panel: t("chat.workspaceFiles.files") }), + ), + ] as const; + } + + protected chatLayoutWidth(sidebarLayout: SidebarLayout): number { + const chatColumn = sidebarLayout.columns.find((column) => + column.panels.some((panel) => panel.slot === "chat"), + ); + return sidebarChatLayoutWidth( + this.paneWidth, + chatColumn?.width ?? sidebarPrimaryWidth(sidebarLayout, this.paneWidth), + isSidebarRegionCollapsed(sidebarLayout, this.paneWidth), + ); + } + + private chatRailMaxWidth(containerSelector: string, siblingSelector?: string): number { + const container = this.querySelector(containerSelector); + const sibling = siblingSelector ? this.querySelector(siblingSelector) : null; + const containerWidth = container?.getBoundingClientRect().width ?? this.paneWidth; + const siblingWidth = sibling?.getBoundingClientRect().width ?? 0; + return ( + containerWidth - siblingWidth - CHAT_RAIL_MAIN_MIN_WIDTH_PX - CHAT_RAIL_DIVIDERS_MAX_WIDTH_PX + ); + } protected sessionRailModeSessionKey = ""; protected sessionRailLoad: Promise | null = null; protected sessionRailCommand: (SessionRailCommand & { sessionKey: string }) | null = null; diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index c5ccb817002d..780ea293e2ea 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -31,8 +31,8 @@ import { } from "./chat-pane-session-controls.ts"; import { SESSION_RAIL_SIDE_MIN_PANE_WIDTH, - WORKSPACE_RAIL_MAX_WIDTH, WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH, + chatMainWidth, } from "./chat-pane-shared.ts"; import { renderSidebarRegion, @@ -69,9 +69,7 @@ import { activatePanel, closeSlot, detachPanelToColumn, - isSidebarRegionCollapsed, mergePanelIntoColumn, - sidebarPrimaryWidth, type SidebarSide, type SidebarSlotId, } from "./sidebar-layout.ts"; @@ -145,9 +143,8 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { approvalSnapshot?.approvalQueue ?? [], state.sessionKey, ); - // Tool rows consult the global title store while rendering; point its - // fetcher at this pane's connection. Requests capture session + agent at - // schedule time, so later renders of other panes cannot re-route them. + // Tool rows consult the global title store while rendering. Requests capture + // session + agent at schedule time, so another pane cannot re-route them. configureToolTitleFetcher({ client: state.connected ? state.client : null, sessionKey: catalogKey ? null : state.sessionKey || null, @@ -199,39 +196,33 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { gatewaySnapshot.client?.instanceId, state.sessionKey, ); - // Never flash "view-only" while metadata loads; after loading, anything short - // of a continuable session (failed lookups too) explains the disabled composer. + // Do not flash view-only while metadata loads; failed lookups still explain + // why the composer is disabled. const catalogDisabledReason = catalogKey && !this.catalogLoading && this.catalogSession?.canContinue !== true ? this.catalogHost?.kind === "node" ? t("chat.catalog.remoteViewOnly") : t("chat.catalog.unsupportedViewOnly") : null; - const sidebarChatColumn = sidebarLayout.columns.find((column) => - column.panels.some((panel) => panel.slot === "chat"), - ); - const sidebarRegionCollapsed = isSidebarRegionCollapsed(sidebarLayout, this.paneWidth); - const chatLayoutWidth = sidebarRegionCollapsed - ? this.paneWidth - : (sidebarChatColumn?.width ?? sidebarPrimaryWidth(sidebarLayout, this.paneWidth)); + const chatLayoutWidth = this.chatLayoutWidth(sidebarLayout); const sessionWorkspace = createSessionWorkspaceProps(state, { draftScope: this.presentationId, narrowLayout: chatLayoutWidth < WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH, }); - const railSideDocked = - !sessionWorkspace.collapsed && - !sessionWorkspace.narrowLayout && - sessionWorkspace.dock !== "bottom"; - // The workspace rail claims the first side slot; tasks need room for both columns. + const railSideDocked = !sessionWorkspace.collapsed && !sessionWorkspace.narrowLayout; + // The workspace claims the first side slot; tasks need room for both columns. const backgroundTasks = createBackgroundTasksProps(state, { narrowLayout: chatLayoutWidth < - WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH + (railSideDocked ? WORKSPACE_RAIL_MAX_WIDTH : 0), + WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH + + (railSideDocked ? this.workspaceRailLayout.width + 4 : 0), }); const tasksSideDocked = !backgroundTasks.collapsed && !backgroundTasks.narrowLayout; - // Only side-docked rails narrow the conversation region. - const sideRailCount = (railSideDocked ? 1 : 0) + (tasksSideDocked ? 1 : 0); - const chatMainWidth = chatLayoutWidth - sideRailCount * WORKSPACE_RAIL_MAX_WIDTH; + const conversationWidth = chatMainWidth( + chatLayoutWidth, + railSideDocked ? this.workspaceRailLayout.width : null, + tasksSideDocked ? this.tasksRailLayout.width : null, + ); const selfUser = resolveCurrentSelfUser({ snapshotUser: gatewaySnapshot.selfUser, presenceEntries: readPresenceEntries(gatewaySnapshot.hello?.snapshot), @@ -300,7 +291,8 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { : this.sessionCompanionThreads.view(state.sessionKey), ...this.sessionRailCommandProps(state.sessionKey), sessionRailMode: this.selectedSessionRailMode(state.sessionKey), - sessionRailDocked: !catalogKey && chatMainWidth >= SESSION_RAIL_SIDE_MIN_PANE_WIDTH, + sessionRailDocked: !catalogKey && conversationWidth >= SESSION_RAIL_SIDE_MIN_PANE_WIDTH, + companionRail: this.companionRailColumn(), onSessionRailSubmit: (question) => void this.submitSessionCompanionQuestion(question), onSessionRailDraftChange: (draft) => this.sessionCompanionThreads.setDraft(state.sessionKey, draft), @@ -311,8 +303,8 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { this.sessionRailMode = mode; } }, - // Unconditional: catalog chats never render the rail (sessionRailReady is - // forced false), and a hide/show from any surface must reach the gateway. + // Catalog chats never render this rail, but hide/show from any surface must + // still reach the gateway. onObserverVisibilityChange: this.setSessionObserverVisibility, gatewayQuestionPrompts: catalogKey || sessionParticipationBlocked ? [] : this.questionPrompts, onGatewayQuestionChange: () => { @@ -433,17 +425,18 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { effortAccess: mutationAccess.effort, }), sessionWorkspace: catalogKey ? undefined : sessionWorkspace, + workspaceRail: this.workspaceRailColumn(), backgroundTasks: catalogKey ? undefined : backgroundTasks, + tasksRail: this.tasksRailColumn(), taskSuggestions: this.taskSuggestions, pullRequests: this.sessionPullRequests.filter( (pullRequest) => !this.dismissedSessionPullRequestIds.has(chatPullRequestId(pullRequest)), ), - // Decided on the undismissed list: a dismissed open PR still exists, so - // the row must not offer creating a duplicate. pullRequestsBranch: createPullRequestBranch( this.sessionPullRequests, this.sessionPullRequestsBranch, ), + // A dismissed open PR still exists, so the row must not offer a duplicate. pullRequestsRateLimited: this.sessionPullRequestsRateLimited, pullRequestsExpanded: this.sessionPullRequestsExpanded, onExpandPullRequests: () => { @@ -496,7 +489,8 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { }, onChatScroll: (event) => this.handleTranscriptScroll(event), onHistoryIntent: (event) => this.handleTranscriptHistoryIntent(event), - // Metadata can resize a committed row; re-enter the scroll owner so the follow lock wins. + // Metadata can resize a committed row; re-enter the scroll owner so the + // follow lock wins. onAssistantAttachmentLoaded: () => scheduleChatScroll(state), getDraft: () => state.chatMessage, onDraftChange: state.handleChatDraftChange, @@ -603,8 +597,20 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { resolveArtifactDownload: (params) => resolveChatArtifactDownload(state, params), basePath: state.basePath, }; - const chat = renderChat(props); - const primary = this.renderBoardPrimary(board, chat); + const header = this.renderPaneHeader( + sessionWorkspace, + backgroundTasks, + selectedSession, + Boolean(catalogKey), + selectedAgent?.workspace, + selectedAgent?.workspaceGit === true, + ); + const chat = renderChat({ ...props, header: board.face === "dashboard" ? nothing : header }); + const boardPrimary = this.renderBoardPrimary(board, chat); + const primary = + board.face === "dashboard" + ? html`
${header}${boardPrimary}
` + : boardPrimary; const discussion = this.buildSessionDiscussionPanel(state, state.sessionKey.trim()); const panelTemplates = { chat, @@ -695,14 +701,7 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { primary, sessionKey: state.sessionKey, }); - return html`${this.renderPaneHeader( - sessionWorkspace, - backgroundTasks, - selectedSession, - Boolean(catalogKey), - selectedAgent?.workspace, - selectedAgent?.workspaceGit === true, - )}${content}${renderChatImageLightbox( + return html`${content}${renderChatImageLightbox( state.imageLightbox, state.handleCloseImage, )}${this.renderResetConfirmation()}`; diff --git a/ui/src/pages/chat/chat-pane-shared.ts b/ui/src/pages/chat/chat-pane-shared.ts index 8e96cb8f2cdd..5cf74a0b438f 100644 --- a/ui/src/pages/chat/chat-pane-shared.ts +++ b/ui/src/pages/chat/chat-pane-shared.ts @@ -166,6 +166,36 @@ export const boardChatDockLayout = createDockPanelLayout({ defaultHeight: 320, defaultWidth: 420, }); + +export const chatWorkspaceRailLayout = createDockPanelLayout({ + storageKey: "openclaw.control.chat-workspace-rail.v1", + minHeight: 180, + minWidth: 260, + defaultDock: "right", + supportedDocks: ["right"], + defaultHeight: 320, + defaultWidth: 280, +}); + +export const chatTasksRailLayout = createDockPanelLayout({ + storageKey: "openclaw.control.chat-tasks-rail.v1", + minHeight: 180, + minWidth: 270, + defaultDock: "right", + supportedDocks: ["right"], + defaultHeight: 320, + defaultWidth: 330, +}); + +export const chatCompanionRailLayout = createDockPanelLayout({ + storageKey: "openclaw.control.chat-companion-rail.v1", + minHeight: 180, + minWidth: 300, + defaultDock: "right", + supportedDocks: ["right"], + defaultHeight: 320, + defaultWidth: 400, +}); export const CATALOG_TOOL_RESULT_PREVIEW_MAX_CHARS = 500; export const CHAT_HISTORY_INTENT_EDGE_PX = 300; export const CHAT_HISTORY_INTENT_IDLE_MS = 200; @@ -245,12 +275,8 @@ export const CHAT_HISTORY_BOOTSTRAP_PAGE_LIMIT = 1; * measured width, never viewport media queries. */ // Side rail (230-280px) plus a readable thread; below this the rail docks bottom. export const WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH = 800; -// Widest the rail's grid column gets; a side-docked rail takes this from the -// width available to the chat + detail-panel split. -export const WORKSPACE_RAIL_MAX_WIDTH = 280; -// Widest the session companion's docked column gets; keep in sync with the -// flex-basis in chat/sidebar.css. Wider than the workspace rail because it -// hosts a reading surface, not a file list. +// The companion defaults wider than the workspace rail because it hosts a +// reading surface, not a file list. const SESSION_RAIL_MAX_WIDTH = 400; // The companion is a side surface, not an overlay: it docks whenever its column // and a readable thread both fit. Measured against the width left after the @@ -258,6 +284,24 @@ const SESSION_RAIL_MAX_WIDTH = 400; // columns, so the companion becomes a full-height sheet instead of covering // the thread as a floating card. export const SESSION_RAIL_SIDE_MIN_PANE_WIDTH = SESSION_RAIL_MAX_WIDTH + 480; +export function sidebarChatLayoutWidth( + paneWidth: number, + sidebarChatWidth: number | undefined, + collapsed: boolean, +): number { + return collapsed ? paneWidth : (sidebarChatWidth ?? paneWidth); +} +export function chatMainWidth( + layoutWidth: number, + workspaceWidth: number | null, + tasksWidth: number | null, +): number { + return ( + layoutWidth - + (workspaceWidth === null ? 0 : workspaceWidth + 4) - + (tasksWidth === null ? 0 : tasksWidth + 4) + ); +} export const NEW_SESSION_ACTIVE_RUN_MESSAGE = "Start a new session after the active run or queued messages finish."; export const NEW_SESSION_LIST_LOADING_MESSAGE = diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 6b77c2c95d7a..969703e52608 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -758,13 +758,8 @@ function createSessionWorkspace( loading: false, error: null, activeId: null, - dock: "right", narrowLayout: false, - dockDragging: false, - dockDragZone: null, onToggleCollapsed: () => undefined, - onSetDock: () => undefined, - onDockDragStart: () => undefined, onRefresh: () => undefined, onBrowsePath: () => undefined, onCopyPath: () => undefined, @@ -1720,8 +1715,8 @@ describe("chat composer workbench", () => { expect(main?.parentElement).toBe(workbench); expect(rail?.parentElement).toBe(workbench); expect(Array.from(workbench?.children ?? []).map((child) => child.className)).toEqual([ - "chat-workspace-rail", "chat-workbench__main", + "chat-workspace-rail", ]); expect(container.querySelector(".chat-workspace-rail__path")?.textContent?.trim()).toBe( "/workspace", @@ -1789,7 +1784,7 @@ describe("chat composer workbench", () => { openSpy.mockRestore(); }); - it("forces the workspace rail to the bottom dock and drops side-dock controls on narrow panes", () => { + it("forces the workspace rail to the bottom dock on narrow panes", () => { const container = renderChatView({ sessionWorkspace: createSessionWorkspace({ narrowLayout: true, @@ -1799,8 +1794,21 @@ describe("chat composer workbench", () => { const workbench = container.querySelector(".chat-workbench"); expect(workbench?.classList.contains("chat-workbench--dock-bottom")).toBe(true); expect(container.querySelector(".chat-workspace-rail")).not.toBeNull(); - expect(container.querySelector(".chat-workspace-rail__dock")).toBeNull(); - expect(container.querySelector(".chat-workspace-rail__grip")).toBeNull(); + }); + + it("keeps the pane header in the conversation column while rails span the workbench", () => { + const container = renderChatView({ + header: html`
Session
`, + sessionWorkspace: createSessionWorkspace(), + workspaceRail: [344, html`
`], + }); + + const workbench = container.querySelector(".chat-workbench"); + const column = container.querySelector(".chat-main__conversation-column"); + expect(column?.firstElementChild?.classList.contains("chat-pane__header")).toBe(true); + expect(container.querySelector(".chat-workspace-rail")?.contains(column)).toBe(false); + expect(workbench?.style.getPropertyValue("--chat-workspace-rail-width")).toBe("344px"); + expect(workbench?.querySelector(".chat-workspace-rail-resizer")).not.toBeNull(); }); it("moves the background-tasks rail to a bottom strip on narrow panes", () => { diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 534e8f89da43..4fbd9eb39f58 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -44,7 +44,11 @@ import type { ChatQueuedEditProps, } from "./components/chat-composer-types.ts"; import { isChatRunWorking, renderChatComposer } from "./components/chat-composer.ts"; -import { inlineChatImageFromEvent, openInlineChatImage } from "./components/chat-image-lightbox.ts"; +import { + inlineChatImageFromEvent, + isImageLightboxEvent, + openInlineChatImage, +} from "./components/chat-image-lightbox.ts"; import type { ArtifactDownloadResolver } from "./components/chat-message-media.ts"; import { renderChatPullRequests } from "./components/chat-pull-requests.ts"; import type { SessionRailCommand, SessionRailMode } from "./components/chat-session-rail.ts"; @@ -73,14 +77,13 @@ import type { ChatRunUiStatus } from "./run-lifecycle.ts"; import type { CompactionStatus, FallbackStatus, PlanStatus } from "./tool-stream.ts"; import type { WorkspaceResultConflict } from "./workspace-conflict.ts"; import "../../components/resizable-divider.ts"; - type ChatReplyTarget = { messageId: string; text: string; senderLabel?: string | null; sourceMessageId?: string | null; }; - +type ChatRailColumn = readonly [width: number, resizer: TemplateResult | typeof nothing]; export type ChatProps = ChatTaskSuggestionTrayProps & ChatCloudStartupNoticeProps & { transcript: ChatTranscriptController; @@ -113,6 +116,7 @@ export type ChatProps = ChatTaskSuggestionTrayProps & sessionRailConsumedCommandGeneration?: number; sessionRailMode?: SessionRailMode; sessionRailDocked?: boolean; + companionRail?: ChatRailColumn; onSessionRailCommandConsumed?: (generation: number) => void; onSessionRailSubmit?: (question: string) => void; onSessionRailDraftChange?: (draft: string) => void; @@ -264,7 +268,10 @@ export type ChatProps = ChatTaskSuggestionTrayProps & onRewindMessage?: (entryId: string) => Promise | boolean; onForkMessage?: (entryId: string) => Promise | void; sessionWorkspace?: SessionWorkspaceProps; + workspaceRail?: ChatRailColumn; backgroundTasks?: BackgroundTasksProps; + tasksRail?: ChatRailColumn; + header?: TemplateResult | typeof nothing; sessionSuggestions?: readonly SessionSuggestion[]; sessionSuggestionRole?: SessionSharingRole; sessionSuggestionBusyIds?: ReadonlySet; @@ -282,23 +289,14 @@ export type ChatProps = ChatTaskSuggestionTrayProps & onDismissPullRequest?: (pullRequest: ControlUiSessionPullRequest) => void; }; -function isImageLightboxEvent(event: Event): boolean { - return event - .composedPath() - .some( - (target) => target instanceof HTMLElement && target.localName === "openclaw-image-lightbox", - ); -} - export function renderChat(props: ChatProps) { const requestUpdate = props.onRequestUpdate ?? (() => {}); const workspaceCollapsed = props.sessionWorkspace?.collapsed !== false; - const workspaceDockBottom = Boolean( - props.sessionWorkspace && - (props.sessionWorkspace.dock === "bottom" || props.sessionWorkspace.narrowLayout), - ); + const workspaceDockBottom = props.sessionWorkspace?.narrowLayout === true; const tasksOpen = props.backgroundTasks?.collapsed === false; const tasksDockBottom = tasksOpen && props.backgroundTasks?.narrowLayout === true; + const workspaceSideOpen = !workspaceCollapsed && !workspaceDockBottom; + const tasksSideOpen = tasksOpen && !tasksDockBottom; const canCompose = props.canSend; const showModelSetupSplash = props.modelSetupRequired === true && @@ -583,84 +581,76 @@ export function renderChat(props: ChatProps) { } }} > - ${renderChatViewNotices(props)} ${renderTranscriptSearch(props.paneId, requestUpdate)}
- ${renderSessionWorkspaceRail(props.sessionWorkspace)} - ${renderBackgroundTasksRail(props.backgroundTasks, backgroundTaskThread)} - ${props.sessionWorkspace?.dockDragging - ? html` - - ` - : nothing}
-
- ${thread} ${scrollToBottomButton} - ${props.inlineApproval && props.onApprovalDecision - ? html`
- ${renderExecApprovalCard({ - approval: props.inlineApproval, - busy: props.approvalBusy === true, - error: props.approvalErrors?.get(props.inlineApproval.id) ?? null, - nowMs: props.approvalNowMs ?? Date.now(), - variant: "inline", - onDecision: props.onApprovalDecision, - })} -
` - : nothing} - ${renderChatTaskSuggestionTray(props)} - ${renderChatPullRequests({ - pullRequests: props.pullRequests ?? [], - branch: props.pullRequestsBranch, - rateLimited: props.pullRequestsRateLimited === true, - expanded: props.pullRequestsExpanded === true, - onExpand: () => props.onExpandPullRequests?.(), - onDismiss: (pullRequest) => props.onDismissPullRequest?.(pullRequest), - })} - ${renderChatSessionSuggestions({ - suggestions: props.sessionSuggestions ?? [], - role: props.sessionSuggestionRole, - busyIds: props.sessionSuggestionBusyIds ?? new Set(), - archived: props.sessionSuggestionsArchived === true, - canResolve: props.canResolveSessionSuggestions === true, - onResolve: (suggestion, resolution) => - props.onResolveSessionSuggestion?.(suggestion, resolution), - })} - ${renderChatSwarmProgress({ - sessions: props.swarmSessions ?? [], - sessionKey: props.sessionKey, - })} - ${showModelSetupSplash ? nothing : chatColumnFooter} +
+ ${props.header ?? nothing} ${renderChatViewNotices(props)} + ${renderTranscriptSearch(props.paneId, requestUpdate)} +
+ ${thread} ${scrollToBottomButton} + ${props.inlineApproval && props.onApprovalDecision + ? html`
+ ${renderExecApprovalCard({ + approval: props.inlineApproval, + busy: props.approvalBusy === true, + error: props.approvalErrors?.get(props.inlineApproval.id) ?? null, + nowMs: props.approvalNowMs ?? Date.now(), + variant: "inline", + onDecision: props.onApprovalDecision, + })} +
` + : nothing} + ${renderChatTaskSuggestionTray(props)} + ${renderChatPullRequests({ + pullRequests: props.pullRequests ?? [], + branch: props.pullRequestsBranch, + rateLimited: props.pullRequestsRateLimited === true, + expanded: props.pullRequestsExpanded === true, + onExpand: () => props.onExpandPullRequests?.(), + onDismiss: (pullRequest) => props.onDismissPullRequest?.(pullRequest), + })} + ${renderChatSessionSuggestions({ + suggestions: props.sessionSuggestions ?? [], + role: props.sessionSuggestionRole, + busyIds: props.sessionSuggestionBusyIds ?? new Set(), + archived: props.sessionSuggestionsArchived === true, + canResolve: props.canResolveSessionSuggestions === true, + onResolve: (suggestion, resolution) => + props.onResolveSessionSuggestion?.(suggestion, resolution), + })} + ${renderChatSwarmProgress({ + sessions: props.swarmSessions ?? [], + sessionKey: props.sessionKey, + })} + ${showModelSetupSplash ? nothing : chatColumnFooter} +
+ ${props.sessionRailReady && + props.sessionRailDocked && + props.sessionRailMode === "expanded" + ? (props.companionRail?.[1] ?? nothing) + : nothing} ${props.sessionRailReady ? html`
+ ${workspaceSideOpen ? (props.workspaceRail?.[1] ?? nothing) : nothing} + ${renderSessionWorkspaceRail(props.sessionWorkspace)} + ${tasksSideOpen ? (props.tasksRail?.[1] ?? nothing) : nothing} + ${renderBackgroundTasksRail(props.backgroundTasks, backgroundTaskThread)}
`; diff --git a/ui/src/pages/chat/components/chat-image-lightbox.ts b/ui/src/pages/chat/components/chat-image-lightbox.ts index 7c55e6fe3771..f6ea13992fd6 100644 --- a/ui/src/pages/chat/components/chat-image-lightbox.ts +++ b/ui/src/pages/chat/components/chat-image-lightbox.ts @@ -4,6 +4,14 @@ import type { ImageLightboxItem } from "../../../components/image-lightbox.ts"; import { t } from "../../../i18n/index.ts"; import { openExternalUrlSafe } from "../../../lib/open-external-url.ts"; +export function isImageLightboxEvent(event: Event): boolean { + return event + .composedPath() + .some( + (target) => target instanceof HTMLElement && target.localName === "openclaw-image-lightbox", + ); +} + export function inlineChatImageFromEvent(event: Event): HTMLImageElement | null { const target = event .composedPath() diff --git a/ui/src/pages/chat/components/chat-session-workspace.ts b/ui/src/pages/chat/components/chat-session-workspace.ts index d8a20eceda38..5d262d296b62 100644 --- a/ui/src/pages/chat/components/chat-session-workspace.ts +++ b/ui/src/pages/chat/components/chat-session-workspace.ts @@ -11,12 +11,7 @@ import type { SessionWorkspaceListResult, } from "../../../api/types.ts"; import { hasOperatorAdminAccess } from "../../../app/operator-access.ts"; -import { - normalizeChatWorkspaceDock, - patchSettings, - type ChatWorkspaceDock, - type UiSettings, -} from "../../../app/settings.ts"; +import type { UiSettings } from "../../../app/settings.ts"; import { icons } from "../../../components/icons.ts"; import { BROWSER_PANEL_TOGGLE_EVENT, @@ -48,15 +43,9 @@ export type SessionWorkspaceProps = { loading: boolean; error: string | null; activeId: string | null; - dock: ChatWorkspaceDock; - /** Pane too narrow for a side rail: presentation forces the bottom dock - * (the persisted dock preference still applies once the pane widens). */ + /** Pane too narrow for a side rail: presentation forces the bottom dock. */ narrowLayout: boolean; - dockDragging: boolean; - dockDragZone: ChatWorkspaceDock | null; onToggleCollapsed: () => void; - onSetDock: (dock: ChatWorkspaceDock) => void; - onDockDragStart: (event: PointerEvent) => void; onRefresh: () => void; onBrowsePath: (path: string) => void; onCopyPath: (path: string) => void; @@ -77,9 +66,6 @@ type SessionWorkspaceState = { browserSearch: string; browserSearchTimer: ReturnType | null; collapsed: boolean; - dock: ChatWorkspaceDock; - dockDragging: boolean; - dockDragZone: ChatWorkspaceDock | null; error: string | null; list: SessionWorkspaceListResult | null; loading: boolean; @@ -159,11 +145,6 @@ function getWorkspaceState(state: SessionWorkspaceHost): SessionWorkspaceState { browserSearch: "", browserSearchTimer: null, collapsed: true, - // Dock preference is app-wide, seeded from the host's loaded settings; - // per-session state just carries it forward. - dock: current?.dock ?? normalizeChatWorkspaceDock(state.settings?.chatWorkspaceDock), - dockDragging: false, - dockDragZone: null, error: null, list: null, loading: false, @@ -625,88 +606,6 @@ export function toggleSessionWorkspace(state: SessionWorkspaceHost) { requestUpdate(state); } -function setSessionWorkspaceDock(state: SessionWorkspaceHost, dock: ChatWorkspaceDock) { - const workspace = getWorkspaceState(state); - if (workspace.dock !== dock) { - workspace.dock = dock; - // Keep the host's settings snapshot in step so the next session's - // workspace state seeds from the same dock without a storage read. - if (state.settings) { - state.settings = { ...state.settings, chatWorkspaceDock: dock }; - } - patchSettings({ chatWorkspaceDock: dock }); - } - requestUpdate(state); -} - -/** Drag the rail by its header to re-dock it inside the pane: the right and - * bottom bands of .chat-workbench are drop zones (mirrors the terminal - * panel's right/bottom dock). A small threshold keeps plain clicks intact. */ -function startSessionWorkspaceDockDrag(state: SessionWorkspaceHost, event: PointerEvent) { - if (event.button !== 0) { - return; - } - const grip = event.currentTarget; - if (!(grip instanceof HTMLElement)) { - return; - } - const workbench = grip.closest(".chat-workbench"); - if (!workbench) { - return; - } - const workspace = getWorkspaceState(state); - const startX = event.clientX; - const startY = event.clientY; - - const resolveZone = (x: number, y: number): ChatWorkspaceDock | null => { - const rect = workbench.getBoundingClientRect(); - if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { - return null; - } - if (y > rect.bottom - rect.height * 0.32) { - return "bottom"; - } - return x > rect.right - rect.width * 0.3 ? "right" : null; - }; - - const handleMove = (move: PointerEvent) => { - if (!workspace.dockDragging) { - if (Math.hypot(move.clientX - startX, move.clientY - startY) < 5) { - return; - } - workspace.dockDragging = true; - workspace.dockDragZone = resolveZone(move.clientX, move.clientY); - requestUpdate(state); - return; - } - const zone = resolveZone(move.clientX, move.clientY); - if (zone !== workspace.dockDragZone) { - workspace.dockDragZone = zone; - requestUpdate(state); - } - }; - const finish = (apply: boolean) => { - grip.removeEventListener("pointermove", handleMove); - grip.removeEventListener("pointerup", handleUp); - grip.removeEventListener("pointercancel", handleCancel); - const zone = workspace.dockDragZone; - workspace.dockDragging = false; - workspace.dockDragZone = null; - if (apply && zone) { - setSessionWorkspaceDock(state, zone); - return; - } - requestUpdate(state); - }; - const handleUp = () => finish(true); - const handleCancel = () => finish(false); - - grip.setPointerCapture(event.pointerId); - grip.addEventListener("pointermove", handleMove); - grip.addEventListener("pointerup", handleUp); - grip.addEventListener("pointercancel", handleCancel); -} - export function revealSessionWorkspaceFile(state: SessionWorkspaceHost, path: string) { const workspace = getWorkspaceState(state); clearWorkspaceSearchTimer(workspace); @@ -779,13 +678,8 @@ export function createSessionWorkspaceProps( loading: workspace.loading, error: workspace.error, activeId: workspace.activeId, - dock: workspace.dock, narrowLayout: options?.narrowLayout === true, - dockDragging: workspace.dockDragging, - dockDragZone: workspace.dockDragZone, onToggleCollapsed: () => toggleSessionWorkspace(state), - onSetDock: (dock) => setSessionWorkspaceDock(state, dock), - onDockDragStart: (event) => startSessionWorkspaceDockDrag(state, event), onRefresh: () => loadWorkspace(state, workspace, true), onBrowsePath: (path) => { clearWorkspaceSearchTimer(workspace); @@ -960,7 +854,6 @@ export function renderSessionWorkspaceRail( } // Narrow panes always present the rail as a bottom strip; a side column // would crush the thread below its readable minimum. - const dock = sessionWorkspace.narrowLayout ? "bottom" : sessionWorkspace.dock; const terminalButton = sessionWorkspace.onToggleTerminal ? html` @@ -1293,41 +1186,12 @@ export function renderSessionWorkspaceRail( return html`