refactor(ui): make chat rails resizable columns

This commit is contained in:
vyctorbrzezowski
2026-08-12 02:10:38 -03:00
parent 1afb5e64a5
commit dadccb4e41
13 changed files with 819 additions and 370 deletions
-9
View File
@@ -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) }
: {}),
+19 -4
View File
@@ -15,6 +15,8 @@ type DockLayoutControllerOptions<TDock extends DockPanelPlacement> = {
reservationPrefix: string;
isAvailable: () => boolean;
isFullscreen?: () => boolean;
maxWidth?: () => number;
reserveViewport?: boolean;
onResize?: () => void;
};
@@ -28,7 +30,7 @@ export class DockLayoutController<TDock extends DockPanelPlacement> implements R
private resizeCleanup: (() => void) | null = null;
private readonly onViewportResize = () => {
const height = Math.min(this.height, this.options.layout.maxHeight());
const width = Math.min(this.width, this.options.layout.maxWidth());
const width = Math.min(this.width, this.maxWidth());
if (height === this.height && width === this.width) {
return;
}
@@ -58,7 +60,7 @@ export class DockLayoutController<TDock extends DockPanelPlacement> implements R
this.open = layout.open && this.options.isAvailable();
this.dock = layout.dock;
this.height = layout.height;
this.width = layout.width;
this.width = Math.min(layout.width, this.maxWidth());
window.addEventListener("resize", this.onViewportResize);
}
@@ -138,7 +140,7 @@ export class DockLayoutController<TDock extends DockPanelPlacement> implements R
}
syncReservation(): void {
if (this.isFullscreen()) {
if (this.isFullscreen() || this.options.reserveViewport === false) {
return;
}
const visible = this.options.isAvailable() && this.open;
@@ -166,7 +168,7 @@ export class DockLayoutController<TDock extends DockPanelPlacement> implements R
this.height = Math.min(next, this.options.layout.maxHeight());
} else {
const next = Math.max(this.options.layout.minWidth, startWidth + (startX - move.clientX));
this.width = Math.min(next, this.options.layout.maxWidth());
this.width = Math.min(next, this.maxWidth());
}
this.syncReservation();
this.options.onResize?.();
@@ -212,6 +214,9 @@ export class DockLayoutController<TDock extends DockPanelPlacement> implements R
}
private clearReservation(): void {
if (this.options.reserveViewport === false) {
return;
}
const root = document.documentElement.style;
root.setProperty(`--oc-${this.options.reservationPrefix}-reserve-bottom`, "0px");
root.setProperty(`--oc-${this.options.reservationPrefix}-reserve-right`, "0px");
@@ -220,6 +225,16 @@ export class DockLayoutController<TDock extends DockPanelPlacement> implements R
private isFullscreen(): boolean {
return this.options.isFullscreen?.() === true;
}
private maxWidth(): number {
return Math.max(
this.options.layout.minWidth,
Math.min(
this.options.layout.maxWidth(),
this.options.maxWidth?.() ?? Number.POSITIVE_INFINITY,
),
);
}
}
export const dockPanelStyles = css`
@@ -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);
});
});
+360
View File
@@ -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<void> {
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<void> {
const geometry = await page.locator(".chat-split-view__cell").evaluate(
(cell, railElement) => {
if (!(railElement instanceof HTMLElement)) {
throw new Error("Rail element is missing");
}
const cellRect = cell.getBoundingClientRect();
const railRect = railElement.getBoundingClientRect();
const headerRect = cell
.querySelector<HTMLElement>(".chat-pane__header")
?.getBoundingClientRect();
return {
cellTop: cellRect.top,
headerBottom: headerRect?.bottom ?? 0,
railTop: railRect.top,
};
},
await rail.elementHandle(),
);
expect(
Math.abs(geometry.railTop - geometry.cellTop),
JSON.stringify(geometry),
).toBeLessThanOrEqual(1);
expect(geometry.railTop, JSON.stringify(geometry)).toBeLessThan(geometry.headerBottom - 10);
}
async function capture(page: Page, name: string): Promise<void> {
if (!proofDir) {
return;
}
await mkdir(proofDir, { recursive: true });
const baseName = `${proofPhase}-${name}-${theme}`;
await page.screenshot({ path: path.join(proofDir, `${baseName}-context.png`), fullPage: true });
await page
.locator(".chat-split-view__cell")
.screenshot({ path: path.join(proofDir, `${baseName}-crop.png`) });
}
suite.define(() => {
it("keeps every chat rail full-height and restores resized inline widths", async () => {
await suite.withPage(
{
colorScheme: theme,
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1600 },
},
async ({ page }) => {
await seedTheme(page);
await page.route("https://discussion.example/embed/channel/**", (route) =>
route.fulfill({
contentType: "text/html; charset=utf-8",
body: `<!doctype html><html><body style="margin:0;padding:20px;font:14px system-ui">
<h2>Session discussion</h2><p>Reviewing the rail layout with the team.</p>
</body></html>`,
}),
);
const gateway = await installMockGateway(page, railScenario());
await page.goto(`${suite.server.baseUrl}chat`);
await page.locator(".chat-group").first().waitFor();
await page.getByRole("button", { name: "Show background tasks" }).click();
await page.getByRole("button", { name: "Show session files", exact: true }).click();
const tasks = page.locator(".chat-tasks-rail");
const workspace = page.locator(".chat-workspace-rail");
await tasks.waitFor();
await workspace.waitFor();
await expect.poll(() => tasks.textContent()).toContain("Verify rail layout");
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");
},
);
});
});
+86
View File
@@ -23,6 +23,8 @@ import {
type QuestionPrompt,
} from "../../app/question-prompt.ts";
import type { PresencePayload } from "../../app/user-profile.ts";
import { DockLayoutController } from "../../components/dock-layout-controller.ts";
import { t } from "../../i18n/index.ts";
import type {
BoardCommandEvent,
BoardProvider,
@@ -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<HTMLElement>(containerSelector);
const sibling = siblingSelector ? this.querySelector<HTMLElement>(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<void> | null = null;
protected sessionRailCommand: (SessionRailCommand & { sessionKey: string }) | null = null;
+39 -40
View File
@@ -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`<div class="chat-pane-primary-column">${header}${boardPrimary}</div>`
: 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()}`;
+50 -6
View File
@@ -166,6 +166,36 @@ export const boardChatDockLayout = createDockPanelLayout({
defaultHeight: 320,
defaultWidth: 420,
});
export const chatWorkspaceRailLayout = createDockPanelLayout({
storageKey: "openclaw.control.chat-workspace-rail.v1",
minHeight: 180,
minWidth: 260,
defaultDock: "right",
supportedDocks: ["right"],
defaultHeight: 320,
defaultWidth: 280,
});
export const chatTasksRailLayout = createDockPanelLayout({
storageKey: "openclaw.control.chat-tasks-rail.v1",
minHeight: 180,
minWidth: 270,
defaultDock: "right",
supportedDocks: ["right"],
defaultHeight: 320,
defaultWidth: 330,
});
export const chatCompanionRailLayout = createDockPanelLayout({
storageKey: "openclaw.control.chat-companion-rail.v1",
minHeight: 180,
minWidth: 300,
defaultDock: "right",
supportedDocks: ["right"],
defaultHeight: 320,
defaultWidth: 400,
});
export const CATALOG_TOOL_RESULT_PREVIEW_MAX_CHARS = 500;
export const CHAT_HISTORY_INTENT_EDGE_PX = 300;
export const CHAT_HISTORY_INTENT_IDLE_MS = 200;
@@ -245,12 +275,8 @@ export const CHAT_HISTORY_BOOTSTRAP_PAGE_LIMIT = 1;
* measured width, never viewport media queries. */
// Side rail (230-280px) plus a readable thread; below this the rail docks bottom.
export const WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH = 800;
// Widest the rail's grid column gets; a side-docked rail takes this from the
// width available to the chat + detail-panel split.
export const WORKSPACE_RAIL_MAX_WIDTH = 280;
// Widest the session companion's docked column gets; keep in sync with the
// flex-basis in chat/sidebar.css. Wider than the workspace rail because it
// hosts a reading surface, not a file list.
// The companion defaults wider than the workspace rail because it hosts a
// reading surface, not a file list.
const SESSION_RAIL_MAX_WIDTH = 400;
// The companion is a side surface, not an overlay: it docks whenever its column
// and a readable thread both fit. Measured against the width left after the
@@ -258,6 +284,24 @@ const SESSION_RAIL_MAX_WIDTH = 400;
// columns, so the companion becomes a full-height sheet instead of covering
// the thread as a floating card.
export const SESSION_RAIL_SIDE_MIN_PANE_WIDTH = SESSION_RAIL_MAX_WIDTH + 480;
export function sidebarChatLayoutWidth(
paneWidth: number,
sidebarChatWidth: number | undefined,
collapsed: boolean,
): number {
return collapsed ? paneWidth : (sidebarChatWidth ?? paneWidth);
}
export function chatMainWidth(
layoutWidth: number,
workspaceWidth: number | null,
tasksWidth: number | null,
): number {
return (
layoutWidth -
(workspaceWidth === null ? 0 : workspaceWidth + 4) -
(tasksWidth === null ? 0 : tasksWidth + 4)
);
}
export const NEW_SESSION_ACTIVE_RUN_MESSAGE =
"Start a new session after the active run or queued messages finish.";
export const NEW_SESSION_LIST_LOADING_MESSAGE =
+17 -9
View File
@@ -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`<header class="chat-pane__header">Session</header>`,
sessionWorkspace: createSessionWorkspace(),
workspaceRail: [344, html`<div class="chat-workspace-rail-resizer"></div>`],
});
const workbench = container.querySelector<HTMLElement>(".chat-workbench");
const column = container.querySelector(".chat-main__conversation-column");
expect(column?.firstElementChild?.classList.contains("chat-pane__header")).toBe(true);
expect(container.querySelector(".chat-workspace-rail")?.contains(column)).toBe(false);
expect(workbench?.style.getPropertyValue("--chat-workspace-rail-width")).toBe("344px");
expect(workbench?.querySelector(".chat-workspace-rail-resizer")).not.toBeNull();
});
it("moves the background-tasks rail to a bottom strip on narrow panes", () => {
+75 -81
View File
@@ -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> | boolean;
onForkMessage?: (entryId: string) => Promise<void> | void;
sessionWorkspace?: SessionWorkspaceProps;
workspaceRail?: ChatRailColumn;
backgroundTasks?: BackgroundTasksProps;
tasksRail?: ChatRailColumn;
header?: TemplateResult | typeof nothing;
sessionSuggestions?: readonly SessionSuggestion[];
sessionSuggestionRole?: SessionSharingRole;
sessionSuggestionBusyIds?: ReadonlySet<string>;
@@ -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)}
<div
class="chat-workbench ${workspaceCollapsed
? "chat-workbench--workspace-collapsed"
: ""} ${workspaceDockBottom ? "chat-workbench--dock-bottom" : ""} ${tasksOpen &&
!tasksDockBottom
? "chat-workbench--tasks-open"
: ""} ${tasksDockBottom ? "chat-workbench--tasks-dock-bottom" : ""}"
: ""} ${workspaceDockBottom ? "chat-workbench--dock-bottom" : ""} ${workspaceSideOpen
? "chat-workbench--workspace-open"
: ""} ${tasksSideOpen ? "chat-workbench--tasks-open" : ""} ${tasksDockBottom
? "chat-workbench--tasks-dock-bottom"
: ""}"
style=${styleMap({
"--chat-workspace-rail-width": `${props.workspaceRail?.[0] ?? 280}px`,
"--chat-tasks-rail-width": `${props.tasksRail?.[0] ?? 330}px`,
})}
>
${renderSessionWorkspaceRail(props.sessionWorkspace)}
${renderBackgroundTasksRail(props.backgroundTasks, backgroundTaskThread)}
${props.sessionWorkspace?.dockDragging
? html`
<div class="chat-workbench__dock-zones" aria-hidden="true">
<div
class="chat-workbench__dock-zone chat-workbench__dock-zone--right ${props
.sessionWorkspace.dockDragZone === "right"
? "chat-workbench__dock-zone--active"
: ""}"
>
<span>${t("chat.workspaceFiles.dockRight")}</span>
</div>
<div
class="chat-workbench__dock-zone chat-workbench__dock-zone--bottom ${props
.sessionWorkspace.dockDragZone === "bottom"
? "chat-workbench__dock-zone--active"
: ""}"
>
<span>${t("chat.workspaceFiles.dockBottom")}</span>
</div>
</div>
`
: nothing}
<div class="chat-workbench__main">
<div class="chat-split-container">
<div
class="chat-main ${props.sessionRailDocked && props.sessionRailMode === "expanded"
? "chat-main--rail-docked"
: ""}"
style=${styleMap({
"--chat-companion-rail-width": `${props.companionRail?.[0] ?? 400}px`,
})}
>
<div class="chat-main__conversation">
${thread} ${scrollToBottomButton}
${props.inlineApproval && props.onApprovalDecision
? html`<div class="chat-inline-approval">
${renderExecApprovalCard({
approval: props.inlineApproval,
busy: props.approvalBusy === true,
error: props.approvalErrors?.get(props.inlineApproval.id) ?? null,
nowMs: props.approvalNowMs ?? Date.now(),
variant: "inline",
onDecision: props.onApprovalDecision,
})}
</div>`
: nothing}
${renderChatTaskSuggestionTray(props)}
${renderChatPullRequests({
pullRequests: props.pullRequests ?? [],
branch: props.pullRequestsBranch,
rateLimited: props.pullRequestsRateLimited === true,
expanded: props.pullRequestsExpanded === true,
onExpand: () => props.onExpandPullRequests?.(),
onDismiss: (pullRequest) => props.onDismissPullRequest?.(pullRequest),
})}
${renderChatSessionSuggestions({
suggestions: props.sessionSuggestions ?? [],
role: props.sessionSuggestionRole,
busyIds: props.sessionSuggestionBusyIds ?? new Set(),
archived: props.sessionSuggestionsArchived === true,
canResolve: props.canResolveSessionSuggestions === true,
onResolve: (suggestion, resolution) =>
props.onResolveSessionSuggestion?.(suggestion, resolution),
})}
${renderChatSwarmProgress({
sessions: props.swarmSessions ?? [],
sessionKey: props.sessionKey,
})}
${showModelSetupSplash ? nothing : chatColumnFooter}
<div class="chat-main__conversation-column">
${props.header ?? nothing} ${renderChatViewNotices(props)}
${renderTranscriptSearch(props.paneId, requestUpdate)}
<div class="chat-main__conversation">
${thread} ${scrollToBottomButton}
${props.inlineApproval && props.onApprovalDecision
? html`<div class="chat-inline-approval">
${renderExecApprovalCard({
approval: props.inlineApproval,
busy: props.approvalBusy === true,
error: props.approvalErrors?.get(props.inlineApproval.id) ?? null,
nowMs: props.approvalNowMs ?? Date.now(),
variant: "inline",
onDecision: props.onApprovalDecision,
})}
</div>`
: nothing}
${renderChatTaskSuggestionTray(props)}
${renderChatPullRequests({
pullRequests: props.pullRequests ?? [],
branch: props.pullRequestsBranch,
rateLimited: props.pullRequestsRateLimited === true,
expanded: props.pullRequestsExpanded === true,
onExpand: () => props.onExpandPullRequests?.(),
onDismiss: (pullRequest) => props.onDismissPullRequest?.(pullRequest),
})}
${renderChatSessionSuggestions({
suggestions: props.sessionSuggestions ?? [],
role: props.sessionSuggestionRole,
busyIds: props.sessionSuggestionBusyIds ?? new Set(),
archived: props.sessionSuggestionsArchived === true,
canResolve: props.canResolveSessionSuggestions === true,
onResolve: (suggestion, resolution) =>
props.onResolveSessionSuggestion?.(suggestion, resolution),
})}
${renderChatSwarmProgress({
sessions: props.swarmSessions ?? [],
sessionKey: props.sessionKey,
})}
${showModelSetupSplash ? nothing : chatColumnFooter}
</div>
</div>
${props.sessionRailReady &&
props.sessionRailDocked &&
props.sessionRailMode === "expanded"
? (props.companionRail?.[1] ?? nothing)
: nothing}
${props.sessionRailReady
? html`
<openclaw-chat-session-rail
@@ -688,6 +678,10 @@ export function renderChat(props: ChatProps) {
</div>
</div>
</div>
${workspaceSideOpen ? (props.workspaceRail?.[1] ?? nothing) : nothing}
${renderSessionWorkspaceRail(props.sessionWorkspace)}
${tasksSideOpen ? (props.tasksRail?.[1] ?? nothing) : nothing}
${renderBackgroundTasksRail(props.backgroundTasks, backgroundTaskThread)}
</div>
</section>
`;
@@ -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()
@@ -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<typeof globalThis.setTimeout> | 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<HTMLElement>(".chat-workbench");
if (!workbench) {
return;
}
const workspace = getWorkspaceState(state);
const startX = event.clientX;
const startY = event.clientY;
const resolveZone = (x: number, y: number): ChatWorkspaceDock | null => {
const rect = workbench.getBoundingClientRect();
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
return null;
}
if (y > rect.bottom - rect.height * 0.32) {
return "bottom";
}
return x > rect.right - rect.width * 0.3 ? "right" : null;
};
const handleMove = (move: PointerEvent) => {
if (!workspace.dockDragging) {
if (Math.hypot(move.clientX - startX, move.clientY - startY) < 5) {
return;
}
workspace.dockDragging = true;
workspace.dockDragZone = resolveZone(move.clientX, move.clientY);
requestUpdate(state);
return;
}
const zone = resolveZone(move.clientX, move.clientY);
if (zone !== workspace.dockDragZone) {
workspace.dockDragZone = zone;
requestUpdate(state);
}
};
const finish = (apply: boolean) => {
grip.removeEventListener("pointermove", handleMove);
grip.removeEventListener("pointerup", handleUp);
grip.removeEventListener("pointercancel", handleCancel);
const zone = workspace.dockDragZone;
workspace.dockDragging = false;
workspace.dockDragZone = null;
if (apply && zone) {
setSessionWorkspaceDock(state, zone);
return;
}
requestUpdate(state);
};
const handleUp = () => finish(true);
const handleCancel = () => finish(false);
grip.setPointerCapture(event.pointerId);
grip.addEventListener("pointermove", handleMove);
grip.addEventListener("pointerup", handleUp);
grip.addEventListener("pointercancel", handleCancel);
}
export function revealSessionWorkspaceFile(state: SessionWorkspaceHost, path: string) {
const workspace = getWorkspaceState(state);
clearWorkspaceSearchTimer(workspace);
@@ -779,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`
<openclaw-tooltip .content=${t("terminal.toggle")}>
@@ -1293,41 +1186,12 @@ export function renderSessionWorkspaceRail(
return html`
<aside class="chat-workspace-rail" aria-label=${t("chat.workspaceFiles.label")}>
<div class="chat-workspace-rail__header">
<!-- Grip: drag the rail onto the pane's right/bottom band to re-dock
it (chat-view renders the drop zones while dragging). -->
<div
class="chat-workspace-rail__title ${sessionWorkspace.narrowLayout
? ""
: "chat-workspace-rail__grip"}"
title=${sessionWorkspace.narrowLayout ? nothing : t("chat.workspaceFiles.dragToDock")}
@pointerdown=${sessionWorkspace.narrowLayout ? nothing : sessionWorkspace.onDockDragStart}
>
<div class="chat-workspace-rail__title">
<span class="chat-workspace-rail__eyebrow">${t("chat.workspaceFiles.workspace")}</span>
<strong>${t("chat.workspaceFiles.files")}</strong>
</div>
<div class="chat-workspace-rail__actions">
${diffButton} ${terminalButton} ${browserButton} ${custodianButton}
${sessionWorkspace.narrowLayout
? nothing
: html`
<openclaw-tooltip
.content=${dock === "bottom"
? t("chat.workspaceFiles.dockRight")
: t("chat.workspaceFiles.dockBottom")}
>
<button
class="btn btn--ghost btn--sm chat-workspace-rail__dock"
type="button"
aria-label=${dock === "bottom"
? t("chat.workspaceFiles.dockRight")
: t("chat.workspaceFiles.dockBottom")}
@click=${() =>
sessionWorkspace.onSetDock(dock === "bottom" ? "right" : "bottom")}
>
${dock === "bottom" ? icons.panelRightOpen : icons.panelBottomOpen}
</button>
</openclaw-tooltip>
`}
<openclaw-tooltip .content=${t("chat.workspaceFiles.refresh")}>
<button
class="btn btn--ghost btn--sm chat-workspace-rail__refresh"
@@ -1349,7 +1213,9 @@ export function renderSessionWorkspaceRail(
@click=${sessionWorkspace.onToggleCollapsed}
>
<span class="nav-collapse-toggle__icon" aria-hidden="true"
>${dock === "bottom" ? icons.panelBottomClose : icons.panelRightClose}</span
>${sessionWorkspace.narrowLayout
? icons.panelBottomClose
: icons.panelRightClose}</span
>
</button>
</openclaw-tooltip>
+89 -76
View File
@@ -2,7 +2,7 @@
.chat-workbench {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(230px, 280px);
grid-template-columns: minmax(0, 1fr);
gap: 0;
flex: 1;
min-height: 0;
@@ -15,6 +15,12 @@
grid-template-columns: minmax(0, 1fr);
}
.chat-workbench--workspace-open {
grid-template-columns:
minmax(0, 1fr) 4px
minmax(260px, var(--chat-workspace-rail-width));
}
/* Bottom dock mirrors the terminal panel: the rail becomes a strip under the
thread instead of a column beside it. */
.chat-workbench--dock-bottom {
@@ -39,13 +45,15 @@
rules sit after the collapse/dock modifiers so the extra column wins at
equal specificity. */
.chat-workbench--tasks-open {
grid-template-columns: minmax(0, 1fr) minmax(270px, 330px);
grid-template-columns:
minmax(0, 1fr) 4px
minmax(270px, var(--chat-tasks-rail-width));
}
.chat-workbench--tasks-open:not(.chat-workbench--workspace-collapsed):not(
.chat-workbench--dock-bottom
) {
grid-template-columns: minmax(0, 1fr) minmax(230px, 280px) minmax(270px, 330px);
.chat-workbench--workspace-open.chat-workbench--tasks-open {
grid-template-columns:
minmax(0, 1fr) 4px minmax(260px, var(--chat-workspace-rail-width)) 4px
minmax(270px, var(--chat-tasks-rail-width));
}
/* Compact icon button shared by the pane header actions. Sizing only chrome
@@ -86,56 +94,6 @@
text-align: center;
}
/* Drop zones shown while dragging the rail's grip; the hovered edge wins. */
.chat-workbench__dock-zones {
position: absolute;
inset: 0;
z-index: 30;
pointer-events: none;
}
.chat-workbench__dock-zone {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed color-mix(in srgb, var(--accent) 45%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--accent) 6%, transparent);
color: var(--muted);
font-size: var(--control-ui-text-sm);
font-weight: 550;
transition:
background var(--duration-fast) ease,
color var(--duration-fast) ease;
}
.chat-workbench__dock-zone--right {
top: 8px;
right: 8px;
bottom: 8px;
width: 26%;
}
.chat-workbench__dock-zone--bottom {
right: 8px;
bottom: 8px;
left: 8px;
height: 28%;
}
.chat-workbench__dock-zone--active {
border-style: solid;
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--text-strong);
}
/* The bottom zone paints over the right zone's lower corner; keep the active
one on top so its label stays readable. */
.chat-workbench__dock-zone--active {
z-index: 1;
}
/* The workspace rail docks flush to the window edge like the terminal panel:
drop the chat content's right gutter and square the card's right corners in
both rail states. Guarded to wide viewports; narrow ones keep the gutter
@@ -173,6 +131,15 @@
flex: 1 1 0;
}
.chat-main__conversation-column {
display: flex;
min-width: 0;
min-height: 0;
flex: 1 1 0;
flex-direction: column;
overflow: hidden;
}
.chat-main__conversation {
position: relative;
display: flex;
@@ -199,10 +166,48 @@
.chat-main--rail-docked > openclaw-chat-session-rail {
display: block;
width: 400px;
min-width: 400px;
width: var(--chat-companion-rail-width);
min-width: 300px;
min-height: 0;
flex: 0 0 400px;
flex: 0 0 var(--chat-companion-rail-width);
}
:is(.chat-workspace-rail-resizer, .chat-tasks-rail-resizer, .chat-companion-rail-resizer) {
position: relative;
z-index: 2;
width: 4px;
min-width: 4px;
cursor: ew-resize;
background: transparent;
}
:is(.chat-workspace-rail-resizer, .chat-tasks-rail-resizer, .chat-companion-rail-resizer)::before {
position: absolute;
inset: 0 -4px;
content: "";
}
:is(.chat-workspace-rail-resizer, .chat-tasks-rail-resizer, .chat-companion-rail-resizer)::after {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
transform: translateX(-50%);
background: var(--border);
content: "";
transition:
background 150ms ease-out,
width 150ms ease-out;
}
:is(
.chat-workspace-rail-resizer,
.chat-tasks-rail-resizer,
.chat-companion-rail-resizer
):hover::after {
width: 2px;
background: var(--accent);
}
openclaw-chat-sidebar-region,
@@ -408,13 +413,12 @@ openclaw-chat-sidebar-region,
}
.chat-workspace-rail {
grid-column: 2;
grid-column: 3;
grid-row: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
border-left: 1px solid var(--border);
background: color-mix(in srgb, var(--panel) 84%, transparent);
}
@@ -456,7 +460,6 @@ openclaw-chat-sidebar-region,
.chat-workspace-rail__terminal,
.chat-workspace-rail__refresh,
.chat-workspace-rail__dock,
.chat-workspace-rail__collapse-toggle {
width: 32px;
min-width: 32px;
@@ -464,18 +467,6 @@ openclaw-chat-sidebar-region,
padding: 0;
}
/* Grab affordance for drag-to-dock. */
.chat-workspace-rail__grip {
cursor: grab;
user-select: none;
-webkit-user-select: none;
touch-action: none;
}
.chat-workspace-rail__grip:active {
cursor: grabbing;
}
.chat-workspace-rail__terminal {
display: inline-flex;
align-items: center;
@@ -512,7 +503,6 @@ openclaw-chat-sidebar-region,
.chat-workspace-rail__terminal svg,
.chat-workspace-rail__refresh svg,
.chat-workspace-rail__dock svg,
.chat-workspace-rail__collapse-toggle svg,
.chat-workspace-rail__file-icon svg {
width: 15px;
@@ -825,10 +815,33 @@ openclaw-chat-sidebar-region,
flex-direction: column;
min-width: 0;
min-height: 0;
border-left: 1px solid var(--border);
background: color-mix(in srgb, var(--panel) 84%, transparent);
}
.chat-workspace-rail-resizer {
grid-column: 2;
grid-row: 1;
}
.chat-workbench--workspace-open.chat-workbench--tasks-open .chat-tasks-rail-resizer {
grid-column: 4;
grid-row: 1;
}
.chat-workbench--tasks-open:not(.chat-workbench--workspace-open) .chat-tasks-rail-resizer {
grid-column: 2;
grid-row: 1;
}
.chat-pane-primary-column {
display: flex;
min-width: 0;
min-height: 0;
flex: 1 1 0;
flex-direction: column;
overflow: hidden;
}
.chat-tasks-rail__header {
display: flex;
align-items: center;
+10 -5
View File
@@ -685,7 +685,8 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-
.shell:not(.shell--nav-collapsed):not(.shell--mobile-nav)
.chat-split-view__column:first-child
> .chat-split-view__cell:first-child
.chat-pane__header {
.chat-main__conversation-column
> .chat-pane__header {
padding-left: 88px;
}
@@ -693,7 +694,8 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-
.shell--nav-collapsed:not(.shell--mobile-nav)
.chat-split-view__column:first-child
> .chat-split-view__cell:first-child
.chat-pane__header {
.chat-main__conversation-column
> .chat-pane__header {
padding-left: 124px;
}
@@ -728,7 +730,8 @@ html.openclaw-native-macos
.shell--nav-collapsed:not(.shell--mobile-nav)
.chat-split-view__column:first-child
> .chat-split-view__cell:first-child
.chat-pane__header {
.chat-main__conversation-column
> .chat-pane__header {
padding-left: 90px;
}
@@ -742,7 +745,8 @@ html.openclaw-native-nav
.shell--nav-collapsed:not(.shell--mobile-nav)
.chat-split-view__column:first-child
> .chat-split-view__cell:first-child
.chat-pane__header {
.chat-main__conversation-column
> .chat-pane__header {
padding-left: 204px;
}
@@ -758,6 +762,7 @@ html.openclaw-native-web-chrome
.shell--nav-collapsed:not(.shell--mobile-nav)
.chat-split-view__column:first-child
> .chat-split-view__cell:first-child
.chat-pane__header {
.chat-main__conversation-column
> .chat-pane__header {
padding-left: 246px;
}