mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
refactor(ui): make chat rails resizable columns
This commit is contained in:
@@ -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) }
|
||||
: {}),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user