mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat: add flexible multi-sidebar chat layout (#113712)
* feat(ui): add persisted sidebar layout model * feat(ui): render flexible chat sidebar columns * test(ui): align board sidebar fixture type * fix(ui): preserve sidebar panels across responsive layout * fix(ui): satisfy sidebar CI ownership and performance * test(ui): derive discussion helpers from panel config * test(ui): mount session rail through its registry * fix(ui): preserve sidebar state across projections * style(ui): format sidebar state fixes * fix(ui): satisfy sidebar lint constraints * refactor(ui): break sidebar layout import cycle * fix(ui): stabilize sidebar panel rendering * fix(ui): keep the narrow sidebar grid off for an empty layout The two-row narrow grid reserved a panel row even with no sidebar panel open, halving the primary surface height on every default mobile chat pane. * fix(ui): lazy-load chat sidebar region * style(ui): format rebased chat state page * chore(ui): raise sidebar startup baseline * fix(ui): preserve sidebar move and resize state * chore(ui): align sidebar startup baseline * chore(ui): refresh sidebar startup baseline * test(ui): register discussion element in isolated test * fix(ui): persist the dragged panel as the collapsed active panel Drag moves activated the panel in its destination column but left the separate persisted collapsed-mode selection stale, so the narrow layout foregrounded the wrong panel after a move and the stale choice survived reload. * fix(ui): preserve resolved canvas URL in detail panel
This commit is contained in:
committed by
GitHub
parent
c539c77057
commit
fb26903d6c
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"startupJsGzipBytes": 319655,
|
||||
"reason": "canvas capability renewal lease",
|
||||
"startupJsGzipBytes": 319835,
|
||||
"reason": "flexible multi-sidebar chat layout",
|
||||
"updatedAt": "2026-07-25"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { openSlot } from "../pages/chat/sidebar-layout.ts";
|
||||
import { createImportedCustomThemeFixture } from "../test-helpers/custom-theme.ts";
|
||||
import { createStorageMock } from "../test-helpers/storage.ts";
|
||||
import {
|
||||
@@ -59,7 +60,6 @@ function makeSettings(gatewayUrl: string, overrides: Partial<UiSettings> = {}):
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 258,
|
||||
sidebarEntries: [],
|
||||
@@ -261,7 +261,6 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 258,
|
||||
sidebarEntries: [],
|
||||
@@ -291,7 +290,6 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 258,
|
||||
sidebarEntries: [],
|
||||
@@ -306,7 +304,6 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 258,
|
||||
sidebarEntries: [],
|
||||
@@ -334,7 +331,6 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 258,
|
||||
sidebarEntries: [],
|
||||
@@ -351,7 +347,6 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
chatPersistCommentary: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 258,
|
||||
sidebarEntries: [],
|
||||
@@ -383,7 +378,6 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 258,
|
||||
sidebarEntries: ["route:tasks", "route:cron"],
|
||||
@@ -443,7 +437,6 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 258,
|
||||
sidebarEntries: [],
|
||||
@@ -756,7 +749,6 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 258,
|
||||
sidebarEntries: [],
|
||||
@@ -770,7 +762,6 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 258,
|
||||
sidebarEntries: [],
|
||||
@@ -797,7 +788,6 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
themeMode: "light",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 320,
|
||||
sidebarEntries: [],
|
||||
@@ -875,6 +865,34 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
expect(loadSettings().boardSessionViews).toEqual({});
|
||||
});
|
||||
|
||||
it("persists normalized sidebar layouts per session", () => {
|
||||
setTestLocation({ protocol: "https:", host: "gateway.example:8443", pathname: "/" });
|
||||
const settings = loadSettings();
|
||||
const sidebarSessionLayouts = {
|
||||
"agent:main:main": openSlot({ columns: [] }, "discussion"),
|
||||
};
|
||||
|
||||
saveSettings({ ...settings, sidebarSessionLayouts });
|
||||
|
||||
expect(loadSettings().sidebarSessionLayouts).toEqual(sidebarSessionLayouts);
|
||||
});
|
||||
|
||||
it("normalizes corrupt stored sidebar layouts to empty columns", () => {
|
||||
setTestLocation({ protocol: "https:", host: "gateway.example:8443", pathname: "/" });
|
||||
const gwUrl = expectedGatewayUrl("");
|
||||
localStorage.setItem(
|
||||
`openclaw.control.settings.v1:${gwUrl}`,
|
||||
JSON.stringify({
|
||||
gatewayUrl: gwUrl,
|
||||
sidebarSessionLayouts: { "agent:main:main": { columns: "invalid" } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(loadSettings().sidebarSessionLayouts).toEqual({
|
||||
"agent:main:main": { columns: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it("omits an invalid stored chat split layout", () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
@@ -908,7 +926,6 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 258,
|
||||
sidebarEntries: [],
|
||||
@@ -937,7 +954,6 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
themeMode: "dark",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 258,
|
||||
sidebarEntries: [],
|
||||
@@ -980,7 +996,6 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 258,
|
||||
sidebarEntries: [],
|
||||
@@ -1022,7 +1037,6 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 258,
|
||||
sidebarEntries: [],
|
||||
|
||||
+22
-9
@@ -40,6 +40,12 @@ import { isSupportedLocale } from "../i18n/index.ts";
|
||||
import { normalizeBoardSessionViews, type BoardSessionViews } from "../lib/board/settings.ts";
|
||||
import { normalizeOptionalString } from "../lib/string-coerce.ts";
|
||||
import { getSafeLocalStorage, getSafeSessionStorage } from "../local-storage.ts";
|
||||
import {
|
||||
normalizeSidebarSessionActivePanels,
|
||||
normalizeSidebarSessionLayouts,
|
||||
type SidebarSessionActivePanels,
|
||||
type SidebarSessionLayouts,
|
||||
} from "../pages/chat/sidebar-layout-persistence.ts";
|
||||
import { normalizeChatSplitLayout, type ChatSplitLayout } from "../pages/chat/split-layout.ts";
|
||||
import { resolveControlUiBasePath } from "./browser.ts";
|
||||
import { parseImportedCustomTheme, type ImportedCustomTheme } from "./custom-theme.ts";
|
||||
@@ -179,10 +185,11 @@ export type UiSettings = {
|
||||
composerHoldToRecord?: boolean;
|
||||
// Camera intent is device-local, not per-agent or synced through config ui.prefs.
|
||||
talkCameraAutoEnable?: boolean;
|
||||
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
||||
chatSplitLayout?: ChatSplitLayout;
|
||||
chatWorkspaceDock?: ChatWorkspaceDock; // Session workspace rail dock edge (default "right")
|
||||
boardSessionViews?: BoardSessionViews; // Last face and active dashboard tab per session
|
||||
sidebarSessionLayouts?: SidebarSessionLayouts; // Sidebar columns and widths per session
|
||||
sidebarSessionActivePanels?: SidebarSessionActivePanels; // Collapsed active panel per session
|
||||
navCollapsed: boolean; // Collapsible sidebar state
|
||||
navWidth: number; // Sidebar width when expanded (240–400px)
|
||||
sidebarEntries: string[]; // Ordered routes, Workboard boards, and pinned sessions below Home
|
||||
@@ -417,7 +424,6 @@ export function loadSettings(): UiSettings {
|
||||
chatPersistCommentary: true,
|
||||
chatSendShortcut: "enter",
|
||||
catalogOpenTarget: "viewer",
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: NAV_WIDTH_DEFAULT,
|
||||
sidebarEntries: [...DEFAULT_SIDEBAR_ENTRIES],
|
||||
@@ -499,15 +505,13 @@ export function loadSettings(): UiSettings {
|
||||
: defaults.composerHoldToRecord,
|
||||
talkCameraAutoEnable:
|
||||
typeof parsed.talkCameraAutoEnable === "boolean" ? parsed.talkCameraAutoEnable : undefined,
|
||||
splitRatio:
|
||||
typeof parsed.splitRatio === "number" &&
|
||||
parsed.splitRatio >= 0.4 &&
|
||||
parsed.splitRatio <= 0.7
|
||||
? parsed.splitRatio
|
||||
: defaults.splitRatio,
|
||||
chatSplitLayout: normalizeChatSplitLayout(parsed.chatSplitLayout),
|
||||
chatWorkspaceDock: normalizeChatWorkspaceDock(parsed.chatWorkspaceDock),
|
||||
boardSessionViews: normalizeBoardSessionViews(parsed.boardSessionViews),
|
||||
sidebarSessionLayouts: normalizeSidebarSessionLayouts(parsed.sidebarSessionLayouts),
|
||||
sidebarSessionActivePanels: normalizeSidebarSessionActivePanels(
|
||||
parsed.sidebarSessionActivePanels,
|
||||
),
|
||||
navCollapsed:
|
||||
typeof parsed.navCollapsed === "boolean" ? parsed.navCollapsed : defaults.navCollapsed,
|
||||
navWidth:
|
||||
@@ -641,13 +645,22 @@ function persistSettings(next: UiSettings, options: { selectGateway?: boolean }
|
||||
...(typeof next.talkCameraAutoEnable === "boolean"
|
||||
? { talkCameraAutoEnable: next.talkCameraAutoEnable }
|
||||
: {}),
|
||||
splitRatio: next.splitRatio,
|
||||
...(next.chatSplitLayout ? { chatSplitLayout: next.chatSplitLayout } : {}),
|
||||
// Right dock is the default; only the opt-in bottom dock persists.
|
||||
...(next.chatWorkspaceDock === "bottom" ? { chatWorkspaceDock: "bottom" as const } : {}),
|
||||
...(next.boardSessionViews && Object.keys(next.boardSessionViews).length > 0
|
||||
? { boardSessionViews: normalizeBoardSessionViews(next.boardSessionViews) }
|
||||
: {}),
|
||||
...(next.sidebarSessionLayouts && Object.keys(next.sidebarSessionLayouts).length > 0
|
||||
? { sidebarSessionLayouts: normalizeSidebarSessionLayouts(next.sidebarSessionLayouts) }
|
||||
: {}),
|
||||
...(next.sidebarSessionActivePanels && Object.keys(next.sidebarSessionActivePanels).length > 0
|
||||
? {
|
||||
sidebarSessionActivePanels: normalizeSidebarSessionActivePanels(
|
||||
next.sidebarSessionActivePanels,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
navCollapsed: next.navCollapsed,
|
||||
navWidth: next.navWidth,
|
||||
sidebarEntries: next.sidebarEntries,
|
||||
|
||||
@@ -13,6 +13,8 @@ class ResizableDivider extends OpenClawLitElement {
|
||||
@property({ type: Number }) maxRatio = 0.7;
|
||||
@property({ type: String }) label = "Resize split view";
|
||||
@property({ type: String, reflect: true }) orientation: "vertical" | "horizontal" = "vertical";
|
||||
@property({ attribute: false }) measureRatio?: () => number;
|
||||
@property({ attribute: false }) measureSize?: () => number;
|
||||
|
||||
private isDragging = false;
|
||||
private startPosition = 0;
|
||||
@@ -129,7 +131,7 @@ class ResizableDivider extends OpenClawLitElement {
|
||||
}
|
||||
this.isDragging = true;
|
||||
this.startPosition = this.orientation === "horizontal" ? e.clientY : e.clientX;
|
||||
this.startRatio = this.splitRatio;
|
||||
this.startRatio = this.currentRatio();
|
||||
this.classList.add("dragging");
|
||||
this.focus();
|
||||
this.capturePointer(e.pointerId);
|
||||
@@ -158,10 +160,19 @@ class ResizableDivider extends OpenClawLitElement {
|
||||
const previousBounds = this.previousElementSibling?.getBoundingClientRect();
|
||||
const nextBounds = this.nextElementSibling?.getBoundingClientRect();
|
||||
const containerBounds = container.getBoundingClientRect();
|
||||
const containerSize =
|
||||
const measuredSize = this.measureSize?.() ?? 0;
|
||||
const siblingSize =
|
||||
this.orientation === "horizontal"
|
||||
? (previousBounds?.height ?? 0) + (nextBounds?.height ?? 0) || containerBounds.height
|
||||
: (previousBounds?.width ?? 0) + (nextBounds?.width ?? 0) || containerBounds.width;
|
||||
? (previousBounds?.height ?? 0) + (nextBounds?.height ?? 0)
|
||||
: (previousBounds?.width ?? 0) + (nextBounds?.width ?? 0);
|
||||
const containerSize =
|
||||
measuredSize > 0
|
||||
? measuredSize
|
||||
: siblingSize ||
|
||||
(this.orientation === "horizontal" ? containerBounds.height : containerBounds.width);
|
||||
if (containerSize <= 0) {
|
||||
return;
|
||||
}
|
||||
const position = this.orientation === "horizontal" ? e.clientY : e.clientX;
|
||||
const deltaRatio = (position - this.startPosition) / containerSize;
|
||||
|
||||
@@ -174,14 +185,15 @@ class ResizableDivider extends OpenClawLitElement {
|
||||
|
||||
private handleKeyDown = (e: KeyboardEvent) => {
|
||||
const step = e.shiftKey ? 0.05 : 0.02;
|
||||
const currentRatio = this.currentRatio();
|
||||
let nextRatio: number | null = null;
|
||||
|
||||
const decreaseKey = this.orientation === "horizontal" ? "ArrowUp" : "ArrowLeft";
|
||||
const increaseKey = this.orientation === "horizontal" ? "ArrowDown" : "ArrowRight";
|
||||
if (e.key === decreaseKey) {
|
||||
nextRatio = this.splitRatio - step;
|
||||
nextRatio = currentRatio - step;
|
||||
} else if (e.key === increaseKey) {
|
||||
nextRatio = this.splitRatio + step;
|
||||
nextRatio = currentRatio + step;
|
||||
} else if (e.key === "Home") {
|
||||
nextRatio = this.minRatio;
|
||||
} else if (e.key === "End") {
|
||||
@@ -224,6 +236,13 @@ class ResizableDivider extends OpenClawLitElement {
|
||||
return Math.max(this.minRatio, Math.min(this.maxRatio, value));
|
||||
}
|
||||
|
||||
private currentRatio() {
|
||||
const measuredRatio = this.measureRatio?.();
|
||||
return measuredRatio !== undefined && Number.isFinite(measuredRatio)
|
||||
? this.clampRatio(measuredRatio)
|
||||
: this.splitRatio;
|
||||
}
|
||||
|
||||
private toAriaValue(value: number) {
|
||||
return Math.round(value * 100);
|
||||
}
|
||||
|
||||
@@ -4213,6 +4213,14 @@ export const en: TranslationMap = {
|
||||
noPreviewableMarkdown: "No previewable markdown content.",
|
||||
noContent: "No content available",
|
||||
},
|
||||
sidebarColumns: {
|
||||
chat: "Chat",
|
||||
discussion: "Discussion",
|
||||
detail: "Details",
|
||||
close: "Close {panel}",
|
||||
drag: "Drag {panel}",
|
||||
resize: "Resize {panel}",
|
||||
},
|
||||
thread: {
|
||||
search: "Search messages",
|
||||
searchPlaceholder: "Search messages...",
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { BoardFace } from "./settings.ts";
|
||||
import type { BoardTab } from "./types.ts";
|
||||
|
||||
export function resolveBoardChatLayoutWidth(params: {
|
||||
paneWidth: number;
|
||||
hasBoard: boolean;
|
||||
face: BoardFace;
|
||||
dock: BoardTab["chatDock"];
|
||||
dockWidth: number;
|
||||
}): number {
|
||||
return params.hasBoard &&
|
||||
params.face === "dashboard" &&
|
||||
(params.dock === "left" || params.dock === "right")
|
||||
? Math.min(params.paneWidth, params.dockWidth)
|
||||
: params.paneWidth;
|
||||
}
|
||||
@@ -230,6 +230,13 @@ function normalizeUiSessionEventKey(
|
||||
return aliases.has(normalized) ? normalizeLowercaseStringOrEmpty(canonicalMain) : normalized;
|
||||
}
|
||||
|
||||
export function canonicalUiSessionKeyForPersistence(
|
||||
host: Pick<UiSessionDefaultsHost, "agentsList" | "hello">,
|
||||
sessionKey: string | undefined | null,
|
||||
): string {
|
||||
return normalizeUiSessionEventKey(host, sessionKey) ?? "";
|
||||
}
|
||||
|
||||
export function areUiSessionKeysEquivalentForHost(
|
||||
host: Pick<UiSessionDefaultsHost, "agentsList" | "hello">,
|
||||
left: string | undefined | null,
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("board session shell", () => {
|
||||
activeTabId: "main",
|
||||
dock: "right" as const,
|
||||
reopenDock: "right" as const,
|
||||
dockSize: { height: 300, width: 420 },
|
||||
dockSize: { height: 300 },
|
||||
chat: html`<div>chat</div>`,
|
||||
divider: html`<div></div>`,
|
||||
canMutate: true,
|
||||
@@ -105,7 +105,7 @@ describe("board session shell", () => {
|
||||
expect(onChange).toHaveBeenCalledWith("dashboard");
|
||||
});
|
||||
|
||||
it.each(["left", "right", "bottom"] as const)("lays chat out on the %s edge", (dock) => {
|
||||
it.each(["left", "right", "bottom"] as const)("lays out the %s dock", (dock) => {
|
||||
const container = createContainer();
|
||||
const provider = boardProviderForSession("agent:main:main");
|
||||
render(
|
||||
@@ -114,7 +114,7 @@ describe("board session shell", () => {
|
||||
activeTabId: "main",
|
||||
dock,
|
||||
reopenDock: "right",
|
||||
dockSize: { height: 300, width: 420 },
|
||||
dockSize: { height: 300 },
|
||||
chat: html`<div data-test-chat>chat</div>`,
|
||||
divider: html`<div class="board-session-surface__divider" data-test-divider></div>`,
|
||||
canMutate: true,
|
||||
@@ -131,8 +131,8 @@ describe("board session shell", () => {
|
||||
);
|
||||
|
||||
expect(container.querySelector(`.board-session-surface--dock-${dock}`)).not.toBeNull();
|
||||
expect(container.querySelector("[data-test-divider]")).not.toBeNull();
|
||||
expect(container.querySelector("[data-test-chat]")).not.toBeNull();
|
||||
expect(container.querySelector("[data-test-divider]") !== null).toBe(dock === "bottom");
|
||||
expect(container.querySelector("[data-test-chat]") !== null).toBe(dock === "bottom");
|
||||
expect(container.querySelector("openclaw-board-view")).not.toBeNull();
|
||||
});
|
||||
|
||||
@@ -146,7 +146,7 @@ describe("board session shell", () => {
|
||||
activeTabId: "main",
|
||||
dock: "hidden",
|
||||
reopenDock: "left",
|
||||
dockSize: { height: 300, width: 420 },
|
||||
dockSize: { height: 300 },
|
||||
chat: html`<div data-test-chat>chat</div>`,
|
||||
divider: html`<div class="board-session-surface__divider"></div>`,
|
||||
canMutate: true,
|
||||
@@ -162,21 +162,21 @@ describe("board session shell", () => {
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector("[data-test-chat]")).not.toBeNull();
|
||||
expect(container.querySelector("[data-test-chat]")).toBeNull();
|
||||
expect(container.querySelector(".board-session-surface--dock-hidden")).not.toBeNull();
|
||||
const reopen = container.querySelector<HTMLButtonElement>(".board-session-surface__reopen");
|
||||
reopen?.click();
|
||||
expect(onDockChange).toHaveBeenCalledWith("left");
|
||||
});
|
||||
|
||||
it("preserves board and chat nodes while changing dock state", () => {
|
||||
it("preserves the board while the bottom chat mounts only for that dock", () => {
|
||||
const container = createContainer();
|
||||
const provider = boardProviderForSession("agent:main:main");
|
||||
const props = {
|
||||
snapshot: provider.snapshot$.value,
|
||||
activeTabId: "main",
|
||||
reopenDock: "left" as const,
|
||||
dockSize: { height: 300, width: 420 },
|
||||
dockSize: { height: 300 },
|
||||
chat: html`<div data-test-chat>chat</div>`,
|
||||
divider: html`<div class="board-session-surface__divider"></div>`,
|
||||
canMutate: true,
|
||||
@@ -192,18 +192,18 @@ describe("board session shell", () => {
|
||||
|
||||
render(renderBoardSessionSurface({ ...props, dock: "right" }), container);
|
||||
const board = container.querySelector("openclaw-board-view");
|
||||
const chat = container.querySelector("[data-test-chat]");
|
||||
expect(container.querySelector("[data-test-chat]")).toBeNull();
|
||||
|
||||
render(renderBoardSessionSurface({ ...props, dock: "left" }), container);
|
||||
expect(container.querySelector("openclaw-board-view")).toBe(board);
|
||||
expect(container.querySelector("[data-test-chat]")).toBe(chat);
|
||||
expect(container.querySelector("[data-test-chat]")).toBeNull();
|
||||
|
||||
render(renderBoardSessionSurface({ ...props, dock: "bottom" }), container);
|
||||
expect(container.querySelector("openclaw-board-view")).toBe(board);
|
||||
expect(container.querySelector("[data-test-chat]")).toBe(chat);
|
||||
expect(container.querySelector("[data-test-chat]")).not.toBeNull();
|
||||
|
||||
render(renderBoardSessionSurface({ ...props, dock: "hidden" }), container);
|
||||
expect(container.querySelector("openclaw-board-view")).toBe(board);
|
||||
expect(container.querySelector("[data-test-chat]")).toBe(chat);
|
||||
expect(container.querySelector("[data-test-chat]")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ import type {
|
||||
|
||||
export type BoardChatDockSize = {
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
export type WorkboardCardChipProps = {
|
||||
@@ -176,17 +175,17 @@ function renderBoardView(props: BoardSessionSurfaceProps) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderChatDock(props: BoardSessionSurfaceProps, dock: BoardVisibleChatDock) {
|
||||
const style =
|
||||
dock === "bottom" ? `height: ${props.dockSize.height}px` : `width: ${props.dockSize.width}px`;
|
||||
return html`<div class="board-session-surface__chat" style=${style}>${props.chat}</div>`;
|
||||
function renderChatDock(props: BoardSessionSurfaceProps) {
|
||||
return html`<div class="board-session-surface__chat" style="height: ${props.dockSize.height}px">
|
||||
${props.chat}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderBoardSessionSurface(props: BoardSessionSurfaceProps) {
|
||||
const layoutDock = props.dock === "hidden" ? props.reopenDock : props.dock;
|
||||
return html`
|
||||
<div class="board-session-surface board-session-surface--dock-${props.dock}">
|
||||
${renderBoardView(props)} ${props.divider} ${renderChatDock(props, layoutDock)}
|
||||
${renderBoardView(props)}
|
||||
${props.dock === "bottom" ? html`${props.divider}${renderChatDock(props)}` : nothing}
|
||||
<button
|
||||
type="button"
|
||||
class="board-session-surface__reopen board-session-surface__reopen--${props.reopenDock}"
|
||||
|
||||
@@ -19,7 +19,6 @@ function createSettings(): UiSettings {
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
chatPersistCommentary: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 280,
|
||||
sidebarEntries: ["route:workboard", "route:tasks"],
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
type SessionCatalogHost,
|
||||
type SessionCatalogSession,
|
||||
type SessionDiscussionState,
|
||||
type SessionDiscussionPanelConfig,
|
||||
type SessionRailMode,
|
||||
type SessionSharingRole,
|
||||
type SessionSuggestion,
|
||||
@@ -219,6 +220,14 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement {
|
||||
protected readonly sessionDiscussionStates = new Map<string, SessionDiscussionState>();
|
||||
protected readonly sessionDiscussionOpenUrls = new Map<string, string | null>();
|
||||
protected readonly sessionDiscussionProbes = new Set<string>();
|
||||
protected readonly sessionDiscussionPanels = new Map<
|
||||
string,
|
||||
{
|
||||
generation: number;
|
||||
canOpen: boolean;
|
||||
config: SessionDiscussionPanelConfig;
|
||||
}
|
||||
>();
|
||||
protected headerRenameInitialLabel: string | null = null;
|
||||
protected headerRenameInitialValue = "";
|
||||
protected headerRenameSessionKey = "";
|
||||
|
||||
@@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { loadSettings, patchSettings } from "../../app/settings.ts";
|
||||
import { resolveBoardChatLayoutWidth } from "../../lib/board/chat-layout.ts";
|
||||
import {
|
||||
boardProviderForSession,
|
||||
type BoardCommandEvent,
|
||||
@@ -14,10 +13,17 @@ import type { ObserverDigestHistory } from "../../lib/observer-digest.ts";
|
||||
import type { SessionCapability } from "../../lib/sessions/index.ts";
|
||||
import { createStorageMock } from "../../test-helpers/storage.ts";
|
||||
import "./chat-pane.ts";
|
||||
import type { ResolvedBoardView } from "./chat-pane-shared.ts";
|
||||
import type { ChatPageHost } from "./chat-state.ts";
|
||||
import {
|
||||
detachPanelToColumn,
|
||||
mergePanelIntoColumn,
|
||||
openSlot,
|
||||
type SidebarLayout,
|
||||
} from "./sidebar-layout.ts";
|
||||
|
||||
type TestChatPane = HTMLElement & {
|
||||
boardChatDockSize: { height: number; width: number };
|
||||
boardChatDockSize: { height: number };
|
||||
boardProvider?: BoardProvider;
|
||||
connectedClient: GatewayBrowserClient | null;
|
||||
connectionGeneration: number;
|
||||
@@ -35,10 +41,17 @@ type TestChatPane = HTMLElement & {
|
||||
dock: "bottom" | "left" | "right",
|
||||
event: CustomEvent<{ splitRatio: number }>,
|
||||
) => void;
|
||||
commitSidebarPanelMove: (
|
||||
layout: SidebarLayout,
|
||||
panelId: string,
|
||||
targetSide: "left" | "right",
|
||||
board: ResolvedBoardView,
|
||||
) => void;
|
||||
syncChatSidebarForDock: (dock: "bottom" | "hidden" | "left" | "right") => boolean;
|
||||
persistBoardSessionView: (patch: { face?: "chat" | "dashboard"; activeTabId?: string }) => void;
|
||||
resolveBoardProvider: () => BoardProvider;
|
||||
refreshBuiltinBoardSnapshot: () => void;
|
||||
resolveBoardView: () => { activeTabId: string; dock: string; face: string; hasBoard: boolean };
|
||||
resolveBoardView: () => ResolvedBoardView;
|
||||
};
|
||||
|
||||
type MockProvider = BoardProvider & { emitCommand(command: BoardCommandEvent["command"]): void };
|
||||
@@ -73,10 +86,20 @@ function createTestPane(sessions: SessionCapability = {} as SessionCapability) {
|
||||
renderLifecycle: { afterCommit: () => () => {}, invalidate: () => {} },
|
||||
requestUpdate: vi.fn(),
|
||||
sessionKey: "agent:main:current",
|
||||
sidebarFocusPanelId: "",
|
||||
sidebarFocusVersion: 0,
|
||||
sidebarLayout: { columns: [] },
|
||||
sessions,
|
||||
sessionsError: null,
|
||||
sessionsLoading: false,
|
||||
} as unknown as ChatPageHost;
|
||||
pane.state.updateSidebarLayout = (layout) => {
|
||||
pane.state.sidebarLayout = layout;
|
||||
};
|
||||
pane.state.updateSidebarActivePanel = (panelId) => {
|
||||
pane.state.sidebarFocusPanelId = panelId;
|
||||
pane.state.sidebarFocusVersion += 1;
|
||||
};
|
||||
pane.connectedClient = client;
|
||||
pane.connectionGeneration = 1;
|
||||
return pane;
|
||||
@@ -252,6 +275,12 @@ describe("chat pane board shell", () => {
|
||||
pane.handleBoardDockChange("left");
|
||||
pane.handleBoardDockChange("hidden");
|
||||
|
||||
expect(
|
||||
pane.state.sidebarLayout.columns.flatMap((column) =>
|
||||
column.panels.map((panel) => panel.slot),
|
||||
),
|
||||
).toContain("chat");
|
||||
|
||||
const reloadedPane = createTestPane();
|
||||
reloadedPane.boardProvider = provider;
|
||||
expect(reloadedPane.resolveBoardView()).toMatchObject({
|
||||
@@ -260,6 +289,60 @@ describe("chat pane board shell", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the board dock when chat is dragged across sides", () => {
|
||||
const pane = createTestPane();
|
||||
const provider = mockBoardProvider("agent:main:current");
|
||||
pane.boardProvider = provider;
|
||||
const renderedLayout = openSlot({ columns: [] }, "chat", "left");
|
||||
pane.state.sidebarLayout = { columns: [] };
|
||||
const chatPanel = renderedLayout.columns[0]!.panels[0]!;
|
||||
const moved = detachPanelToColumn(renderedLayout, chatPanel.id, "right", 0);
|
||||
const board = { ...pane.resolveBoardView(), dock: "left" as const };
|
||||
|
||||
pane.commitSidebarPanelMove(moved, chatPanel.id, "right", board);
|
||||
|
||||
expect(pane.state.sidebarLayout.columns[0]?.side).toBe("right");
|
||||
expect(pane.resolveBoardView().dock).toBe("right");
|
||||
});
|
||||
|
||||
it("persists the moved panel as the collapsed active panel", () => {
|
||||
const pane = createTestPane();
|
||||
const renderedLayout = openSlot(openSlot({ columns: [] }, "detail"), "discussion");
|
||||
pane.state.sidebarLayout = renderedLayout;
|
||||
const discussionPanel = renderedLayout.columns[1]!.panels[0]!;
|
||||
const moved = mergePanelIntoColumn(
|
||||
renderedLayout,
|
||||
discussionPanel.id,
|
||||
renderedLayout.columns[0]!.id,
|
||||
0,
|
||||
);
|
||||
|
||||
pane.commitSidebarPanelMove(moved, discussionPanel.id, "right", pane.resolveBoardView());
|
||||
|
||||
// The collapsed layout reads this separate selection, so a drag must update it
|
||||
// or the narrow view foregrounds a stale panel after resizing.
|
||||
expect(pane.state.sidebarFocusPanelId).toBe(discussionPanel.id);
|
||||
});
|
||||
|
||||
it("activates an existing tabbed chat panel when reopening a side dock", () => {
|
||||
const pane = createTestPane();
|
||||
const withChat = openSlot(openSlot({ columns: [] }, "chat"), "discussion");
|
||||
const chatPanel = withChat.columns[0]!.panels[0]!;
|
||||
const discussionPanel = withChat.columns[1]!.panels[0]!;
|
||||
pane.state.sidebarLayout = mergePanelIntoColumn(
|
||||
withChat,
|
||||
discussionPanel.id,
|
||||
withChat.columns[0]!.id,
|
||||
1,
|
||||
);
|
||||
pane.state.sidebarLayout.columns[0]!.activePanelId = discussionPanel.id;
|
||||
|
||||
expect(pane.syncChatSidebarForDock("right")).toBe(true);
|
||||
|
||||
expect(pane.state.sidebarLayout.columns[0]?.activePanelId).toBe(chatPanel.id);
|
||||
expect(pane.state.sidebarFocusPanelId).toBe(chatPanel.id);
|
||||
});
|
||||
|
||||
it("restores one board view across equivalent main session keys", () => {
|
||||
const pane = createTestPane();
|
||||
pane.context = {
|
||||
@@ -457,42 +540,21 @@ describe("chat pane board shell", () => {
|
||||
expect(provider.canPinMcpApps).toBe(profile.canMutate);
|
||||
});
|
||||
|
||||
it("uses the side dock width for rail and detail breakpoints", () => {
|
||||
expect(
|
||||
resolveBoardChatLayoutWidth({
|
||||
paneWidth: 1400,
|
||||
hasBoard: true,
|
||||
face: "dashboard",
|
||||
dock: "right",
|
||||
dockWidth: 420,
|
||||
}),
|
||||
).toBe(420);
|
||||
expect(
|
||||
resolveBoardChatLayoutWidth({
|
||||
paneWidth: 1400,
|
||||
hasBoard: true,
|
||||
face: "dashboard",
|
||||
dock: "bottom",
|
||||
dockWidth: 420,
|
||||
}),
|
||||
).toBe(1400);
|
||||
});
|
||||
|
||||
it("persists dashboard chat dock resizing across pane recreation", () => {
|
||||
it("persists bottom chat dock resizing across pane recreation", () => {
|
||||
const pane = createTestPane();
|
||||
const previous = document.createElement("div");
|
||||
const divider = document.createElement("div");
|
||||
const next = document.createElement("div");
|
||||
previous.getBoundingClientRect = () => ({ width: 650 }) as DOMRect;
|
||||
next.getBoundingClientRect = () => ({ width: 350 }) as DOMRect;
|
||||
previous.getBoundingClientRect = () => ({ height: 650 }) as DOMRect;
|
||||
next.getBoundingClientRect = () => ({ height: 350 }) as DOMRect;
|
||||
const container = document.createElement("div");
|
||||
container.append(previous, divider, next);
|
||||
divider.addEventListener("resize", (event) => {
|
||||
pane.handleBoardDockResize("right", event as unknown as CustomEvent<{ splitRatio: number }>);
|
||||
pane.handleBoardDockResize("bottom", event as unknown as CustomEvent<{ splitRatio: number }>);
|
||||
});
|
||||
divider.dispatchEvent(new CustomEvent("resize", { detail: { splitRatio: 0.65 } }));
|
||||
|
||||
const recreated = createTestPane();
|
||||
expect(recreated.boardChatDockSize.width).toBe(350);
|
||||
expect(recreated.boardChatDockSize.height).toBe(350);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,12 @@ import {
|
||||
loadSettings,
|
||||
normalizeSessionKeyForUiComparison,
|
||||
patchSettings,
|
||||
SIDEBAR_NARROW_BREAKPOINT_PX,
|
||||
activatePanel,
|
||||
detachPanelToColumn,
|
||||
fitSidebarLayout,
|
||||
openSlot,
|
||||
resizeColumn,
|
||||
renderChatResizableDivider,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveSessionKey,
|
||||
@@ -23,6 +29,8 @@ import {
|
||||
type BoardTab,
|
||||
type BoardViewSnapshot,
|
||||
type SessionObserverDigest,
|
||||
type SidebarLayout,
|
||||
type SidebarSide,
|
||||
type WorkboardCardChipProps,
|
||||
} from "./chat-pane-deps.ts";
|
||||
import { ChatPaneHistory } from "./chat-pane-history.ts";
|
||||
@@ -33,6 +41,111 @@ import {
|
||||
} from "./chat-pane-shared.ts";
|
||||
|
||||
export abstract class ChatPaneBoard extends ChatPaneHistory {
|
||||
protected commitSidebarLayout(layout: SidebarLayout): void {
|
||||
const state = this.state;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const fitted =
|
||||
this.paneWidth >= SIDEBAR_NARROW_BREAKPOINT_PX
|
||||
? (fitSidebarLayout(layout, this.paneWidth) ?? layout)
|
||||
: layout;
|
||||
state.updateSidebarLayout(fitted);
|
||||
}
|
||||
|
||||
protected commitSidebarPanelMove(
|
||||
layout: SidebarLayout,
|
||||
panelId: string,
|
||||
targetSide: SidebarSide,
|
||||
board: ResolvedBoardView,
|
||||
): void {
|
||||
const panel = layout.columns
|
||||
.flatMap((column) => column.panels)
|
||||
.find((candidate) => candidate.id === panelId);
|
||||
if (panel?.slot !== "chat" || board.dock === targetSide) {
|
||||
this.commitSidebarLayout(layout);
|
||||
this.commitSidebarMovedPanelActive(panelId);
|
||||
return;
|
||||
}
|
||||
if (!board.provider.canMutate || board.activeTabReadOnly) {
|
||||
return;
|
||||
}
|
||||
this.commitSidebarLayout(layout);
|
||||
this.commitSidebarMovedPanelActive(panelId);
|
||||
this.handleBoardDockChange(targetSide);
|
||||
}
|
||||
|
||||
// A move activates the panel in its destination column, but the collapsed layout
|
||||
// reads a separate persisted selection. Without this the narrow view foregrounds
|
||||
// a stale panel after a drag, and the stale choice survives reload.
|
||||
private commitSidebarMovedPanelActive(panelId: string): void {
|
||||
this.state?.updateSidebarActivePanel(panelId);
|
||||
}
|
||||
|
||||
protected commitSidebarColumnResize(
|
||||
renderedLayout: SidebarLayout,
|
||||
columnId: string,
|
||||
width: number,
|
||||
): void {
|
||||
const state = this.state;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const resizedProjection = resizeColumn(renderedLayout, columnId, width);
|
||||
const fittedProjection =
|
||||
this.paneWidth >= SIDEBAR_NARROW_BREAKPOINT_PX
|
||||
? (fitSidebarLayout(resizedProjection, this.paneWidth) ?? resizedProjection)
|
||||
: resizedProjection;
|
||||
const fittedWidth = fittedProjection.columns.find((column) => column.id === columnId)?.width;
|
||||
if (
|
||||
fittedWidth !== undefined &&
|
||||
state.sidebarLayout.columns.some((column) => column.id === columnId)
|
||||
) {
|
||||
state.updateSidebarLayout(resizeColumn(state.sidebarLayout, columnId, fittedWidth));
|
||||
return;
|
||||
}
|
||||
this.commitSidebarLayout(fittedProjection);
|
||||
}
|
||||
|
||||
protected syncChatSidebarForDock(dock: BoardTab["chatDock"]): boolean {
|
||||
const state = this.state;
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
if (dock !== "left" && dock !== "right") {
|
||||
return true;
|
||||
}
|
||||
const beforeOpen = state.sidebarLayout;
|
||||
let layout = openSlot(beforeOpen, "chat", dock);
|
||||
const chatColumn = layout.columns.find((column) =>
|
||||
column.panels.some((panel) => panel.slot === "chat"),
|
||||
);
|
||||
if (chatColumn && chatColumn.side !== dock) {
|
||||
const panel = chatColumn.panels.find((candidate) => candidate.slot === "chat");
|
||||
if (panel) {
|
||||
layout = detachPanelToColumn(layout, panel.id, dock, 0);
|
||||
}
|
||||
}
|
||||
const chatPanel = layout.columns
|
||||
.flatMap((column) => column.panels)
|
||||
.find((panel) => panel.slot === "chat");
|
||||
if (chatPanel) {
|
||||
layout = activatePanel(layout, chatPanel.id);
|
||||
}
|
||||
const newColumn = layout.columns.find(
|
||||
(column) => !beforeOpen.columns.some((current) => current.id === column.id),
|
||||
);
|
||||
const fitted =
|
||||
this.paneWidth >= SIDEBAR_NARROW_BREAKPOINT_PX
|
||||
? (fitSidebarLayout(layout, this.paneWidth, newColumn?.id) ?? layout)
|
||||
: layout;
|
||||
state.updateSidebarLayout(fitted);
|
||||
if (chatPanel) {
|
||||
state.updateSidebarActivePanel(chatPanel.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected resolveBoardProvider(): BoardProvider {
|
||||
const sessionKey = resolveSessionKey(
|
||||
this.state?.sessionKey ?? this.sessionKey,
|
||||
@@ -313,6 +426,9 @@ export abstract class ChatPaneBoard extends ChatPaneHistory {
|
||||
return;
|
||||
}
|
||||
const reopenDock = command.dock === "hidden" ? board.reopenDock : command.dock;
|
||||
if (!this.syncChatSidebarForDock(command.dock)) {
|
||||
return;
|
||||
}
|
||||
this.persistBoardReopenDock(board, reopenDock);
|
||||
this.boardCommandDock = {
|
||||
sessionKey,
|
||||
@@ -330,6 +446,9 @@ export abstract class ChatPaneBoard extends ChatPaneHistory {
|
||||
return;
|
||||
}
|
||||
const sessionKey = this.resolveBoardSessionKey(board.snapshot.sessionKey);
|
||||
if (!this.syncChatSidebarForDock(dock)) {
|
||||
return;
|
||||
}
|
||||
this.boardCommandDock = null;
|
||||
const reopenDock = dock === "hidden" ? board.reopenDock : dock;
|
||||
this.lastVisibleBoardDock.set(`${sessionKey}:${board.activeTabId}`, reopenDock);
|
||||
@@ -371,6 +490,9 @@ export abstract class ChatPaneBoard extends ChatPaneHistory {
|
||||
dock: VisibleBoardDock,
|
||||
event: CustomEvent<{ splitRatio: number }>,
|
||||
): void {
|
||||
if (dock !== "bottom") {
|
||||
return;
|
||||
}
|
||||
const divider = event.currentTarget as HTMLElement | null;
|
||||
const previous = divider?.previousElementSibling?.getBoundingClientRect();
|
||||
const next = divider?.nextElementSibling?.getBoundingClientRect();
|
||||
@@ -381,25 +503,15 @@ export abstract class ChatPaneBoard extends ChatPaneHistory {
|
||||
if (total <= 0) {
|
||||
return;
|
||||
}
|
||||
if (dock === "bottom") {
|
||||
this.boardChatDockSize = {
|
||||
...this.boardChatDockSize,
|
||||
height: Math.min(
|
||||
boardChatDockLayout.maxHeight(),
|
||||
Math.max(boardChatDockLayout.minHeight, total * (1 - event.detail.splitRatio)),
|
||||
),
|
||||
};
|
||||
} else {
|
||||
const dockRatio = dock === "left" ? event.detail.splitRatio : 1 - event.detail.splitRatio;
|
||||
this.boardChatDockSize = {
|
||||
...this.boardChatDockSize,
|
||||
width: Math.min(
|
||||
boardChatDockLayout.maxWidth(),
|
||||
Math.max(boardChatDockLayout.minWidth, total * dockRatio),
|
||||
),
|
||||
};
|
||||
}
|
||||
this.boardChatDockSize = {
|
||||
...this.boardChatDockSize,
|
||||
height: Math.min(
|
||||
boardChatDockLayout.maxHeight(),
|
||||
Math.max(boardChatDockLayout.minHeight, total * (1 - event.detail.splitRatio)),
|
||||
),
|
||||
};
|
||||
boardChatDockLayout.save({
|
||||
...boardChatDockLayout.load(),
|
||||
...this.boardChatDockSize,
|
||||
open: true,
|
||||
dock,
|
||||
|
||||
@@ -3,10 +3,13 @@ import {
|
||||
applySelectedSessionProjection,
|
||||
areUiSessionKeysEquivalent,
|
||||
buildAgentMainSessionKey,
|
||||
canonicalUiSessionKeyForPersistence,
|
||||
clearChatMessagesFromCache,
|
||||
hasOperatorAdminAccess,
|
||||
isGatewayMethodAdvertised,
|
||||
loadSettings,
|
||||
markQueuedChatSendsWaitingForReconnect,
|
||||
normalizeSidebarLayout,
|
||||
parseAgentSessionKey,
|
||||
parseCatalogSessionKey,
|
||||
readPresenceEntries,
|
||||
@@ -142,6 +145,7 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle {
|
||||
return;
|
||||
}
|
||||
const wasConnected = state.connected;
|
||||
const previousSidebarSessionKey = canonicalUiSessionKeyForPersistence(state, state.sessionKey);
|
||||
const sourceChanged =
|
||||
state.client !== snapshot.client || wasConnected !== (snapshot.phase === "connected");
|
||||
const clientChanged = this.connectedClient !== snapshot.client;
|
||||
@@ -167,6 +171,7 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle {
|
||||
this.clearTypingActors();
|
||||
this.sessionDiscussionStates.clear();
|
||||
this.sessionDiscussionOpenUrls.clear();
|
||||
this.sessionDiscussionPanels.clear();
|
||||
this.sessionParticipationTracker.reset();
|
||||
// A new gateway/account owns its own membership + identity data; drop the
|
||||
// previous connection's sharing cache so a stale loading entry cannot
|
||||
@@ -181,15 +186,25 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle {
|
||||
state.connectionEpoch = this.connectionGeneration;
|
||||
state.hello = snapshot.hello;
|
||||
state.canvasPluginSurfaceUrl = snapshot.canvasPluginSurfaceUrl;
|
||||
const sidebarSessionKey = canonicalUiSessionKeyForPersistence(state, state.sessionKey);
|
||||
const sidebarKeyChanged = sidebarSessionKey !== previousSidebarSessionKey;
|
||||
if (sidebarSessionKey && (clientChanged || sidebarKeyChanged)) {
|
||||
const sidebarSettings = loadSettings();
|
||||
const persistedLayout = sidebarSettings.sidebarSessionLayouts?.[sidebarSessionKey];
|
||||
if (persistedLayout !== undefined) {
|
||||
state.sidebarLayout = normalizeSidebarLayout(persistedLayout);
|
||||
} else if (clientChanged) {
|
||||
state.sidebarLayout = { columns: [] };
|
||||
} else if (state.sidebarLayout.columns.length > 0) {
|
||||
state.updateSidebarLayout(state.sidebarLayout);
|
||||
}
|
||||
state.sidebarFocusPanelId =
|
||||
sidebarSettings.sidebarSessionActivePanels?.[sidebarSessionKey] ?? "";
|
||||
state.sidebarFocusVersion += 1;
|
||||
}
|
||||
if (state.connected && state.pendingAbort) {
|
||||
void replayPendingChatAbort(state).finally(() => state.requestUpdate?.());
|
||||
}
|
||||
if (sourceChanged && state.sidebarContent?.kind === "session-discussion") {
|
||||
// A reconnect may point at a different gateway/provider; an open panel
|
||||
// would keep rendering the previous provider's URL. Close it — the
|
||||
// re-probe below restores the action for the new source.
|
||||
state.handleCloseSidebar();
|
||||
}
|
||||
if (sourceChanged && snapshot.phase === "connected" && state.sessionKey) {
|
||||
// Reconnects clear the probed states above; re-probe the active session
|
||||
// so source-owned affordances reappear without a manual session switch.
|
||||
|
||||
@@ -85,7 +85,6 @@ export {
|
||||
hasSessionPresenceViewers,
|
||||
} from "../../components/viewer-facepile.ts";
|
||||
export { t } from "../../i18n/index.ts";
|
||||
export { resolveBoardChatLayoutWidth } from "../../lib/board/chat-layout.ts";
|
||||
export {
|
||||
acquireBoardProviderForSession,
|
||||
boardProviderCacheKey,
|
||||
@@ -135,6 +134,7 @@ export {
|
||||
export {
|
||||
areUiSessionKeysEquivalent,
|
||||
buildAgentMainSessionKey,
|
||||
canonicalUiSessionKeyForPersistence,
|
||||
normalizeSessionKeyForUiComparison,
|
||||
parseAgentSessionKey,
|
||||
resolveAgentIdFromSessionKey,
|
||||
@@ -209,6 +209,22 @@ export {
|
||||
} from "./chat-state.ts";
|
||||
export { resetChatViewState } from "./chat-view-state.ts";
|
||||
export { renderChat, type ChatProps } from "./chat-view.ts";
|
||||
export {
|
||||
SIDEBAR_NARROW_BREAKPOINT_PX,
|
||||
activatePanel,
|
||||
closeSlot,
|
||||
detachPanelToColumn,
|
||||
fitSidebarLayout,
|
||||
isSidebarRegionCollapsed,
|
||||
mergePanelIntoColumn,
|
||||
sidebarPrimaryWidth,
|
||||
normalizeSidebarLayout,
|
||||
openSlot,
|
||||
resizeColumn,
|
||||
type SidebarLayout,
|
||||
type SidebarSide,
|
||||
type SidebarSlotId,
|
||||
} from "./sidebar-layout.ts";
|
||||
export { renderCatalogTerminalButton } from "./components/catalog-terminal-button.ts";
|
||||
export { chatAttachmentFromDataUrl } from "./components/chat-attachments.ts";
|
||||
export {
|
||||
@@ -246,12 +262,7 @@ export {
|
||||
toggleSessionWorkspace,
|
||||
type SessionWorkspaceProps,
|
||||
} from "./components/chat-session-workspace.ts";
|
||||
export {
|
||||
CHAT_DETAIL_FULL_MESSAGE_MAX_CHARS,
|
||||
type DetailFullMessageResult,
|
||||
type SidebarContent,
|
||||
type SidebarFullMessageRequest,
|
||||
} from "./components/chat-sidebar.ts";
|
||||
export type { SessionDiscussionPanelConfig } from "./components/session-discussion-panel.ts";
|
||||
export {
|
||||
ChatTranscriptController,
|
||||
resetChatThreadPresentationState,
|
||||
|
||||
@@ -126,6 +126,7 @@ export abstract class ChatPaneHeaderRender extends ChatPaneHeader {
|
||||
></openclaw-viewer-facepile>`
|
||||
: nothing,
|
||||
faceControl: renderBoardFaceToggle(board.hasBoard, board.face, (face) => {
|
||||
this.syncChatSidebarForDock(face === "dashboard" ? board.dock : "hidden");
|
||||
this.persistBoardSessionView({ face });
|
||||
}),
|
||||
sharingControl:
|
||||
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
isCloudWorkerPlacementState,
|
||||
isGatewayMethodAdvertised,
|
||||
parseAgentSessionKey,
|
||||
SIDEBAR_NARROW_BREAKPOINT_PX,
|
||||
activatePanel,
|
||||
closeSlot,
|
||||
fitSidebarLayout,
|
||||
nothing,
|
||||
openSlot,
|
||||
t,
|
||||
type ChatPaneHeaderAction,
|
||||
type GatewayBrowserClient,
|
||||
@@ -15,7 +20,7 @@ import {
|
||||
type SessionDiscussionInfo,
|
||||
type SessionDiscussionState,
|
||||
type SessionsFilesRevealResult,
|
||||
type SidebarContent,
|
||||
type SessionDiscussionPanelConfig,
|
||||
type SystemInfoResult,
|
||||
type WorktreesBranchesResult,
|
||||
type WorktreesListResult,
|
||||
@@ -286,7 +291,7 @@ export abstract class ChatPaneHeader extends ChatPaneContext {
|
||||
// key's cached state on every session switch (and reconnect clears all), so
|
||||
// each activation resolves a fresh probe and reaches this. Within one
|
||||
// activation the cache dedupes — closing the sidebar sticks, and an
|
||||
// already-open sidebar is never stolen.
|
||||
// already-open discussion column is never duplicated.
|
||||
protected maybeAutoShowSessionDiscussion(
|
||||
sessionKey: string,
|
||||
discussionState: SessionDiscussionState,
|
||||
@@ -296,20 +301,19 @@ export abstract class ChatPaneHeader extends ChatPaneContext {
|
||||
discussionState !== "open" ||
|
||||
!state ||
|
||||
state.sessionKey.trim() !== sessionKey ||
|
||||
state.sidebarOpen
|
||||
state.sidebarLayout.columns.some((column) =>
|
||||
column.panels.some((panel) => panel.slot === "discussion"),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const content = this.buildSessionDiscussionContent(state, sessionKey);
|
||||
if (content) {
|
||||
state.handleOpenSidebar(content);
|
||||
}
|
||||
this.openSessionDiscussionSlot();
|
||||
}
|
||||
|
||||
protected buildSessionDiscussionContent(
|
||||
protected buildSessionDiscussionPanel(
|
||||
state: NonNullable<typeof this.state>,
|
||||
sessionKey: string,
|
||||
): SidebarContent | null {
|
||||
): SessionDiscussionPanelConfig | null {
|
||||
if (!state.connected || !state.client) {
|
||||
return null;
|
||||
}
|
||||
@@ -317,8 +321,12 @@ export abstract class ChatPaneHeader extends ChatPaneContext {
|
||||
hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null) &&
|
||||
isGatewayMethodAdvertised(this.context.gateway.snapshot, "session.discussion.open") === true;
|
||||
const contentGeneration = this.connectionGeneration;
|
||||
const content: SidebarContent = {
|
||||
kind: "session-discussion",
|
||||
const cached = this.sessionDiscussionPanels.get(sessionKey);
|
||||
if (cached?.generation === contentGeneration && cached.canOpen === canOpen) {
|
||||
cached.config.openUrl = this.sessionDiscussionOpenUrls.get(sessionKey) ?? null;
|
||||
return cached.config;
|
||||
}
|
||||
const config: SessionDiscussionPanelConfig = {
|
||||
sessionKey,
|
||||
canOpen,
|
||||
openUrl: this.sessionDiscussionOpenUrls.get(sessionKey) ?? null,
|
||||
@@ -352,26 +360,45 @@ export abstract class ChatPaneHeader extends ChatPaneContext {
|
||||
if (discussionState === "none") {
|
||||
this.sessionDiscussionOpenUrls.delete(key);
|
||||
}
|
||||
const current = state.sidebarContent;
|
||||
if (
|
||||
discussionState === "none" &&
|
||||
current?.kind === "session-discussion" &&
|
||||
current.sessionKey === key
|
||||
) {
|
||||
state.handleCloseSidebar();
|
||||
if (discussionState === "none" && isCurrentSession) {
|
||||
state.updateSidebarLayout(closeSlot(state.sidebarLayout, "discussion"));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isCurrentSession &&
|
||||
current?.kind === "session-discussion" &&
|
||||
current.sessionKey === key
|
||||
) {
|
||||
state.sidebarContent = { ...current, openUrl };
|
||||
}
|
||||
state.requestUpdate();
|
||||
},
|
||||
};
|
||||
return content;
|
||||
this.sessionDiscussionPanels.set(sessionKey, {
|
||||
generation: contentGeneration,
|
||||
canOpen,
|
||||
config,
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
protected openSessionDiscussionSlot(): boolean {
|
||||
const state = this.state;
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
let opened = openSlot(state.sidebarLayout, "discussion", "right");
|
||||
const discussionPanel = opened.columns
|
||||
.flatMap((column) => column.panels)
|
||||
.find((panel) => panel.slot === "discussion");
|
||||
if (discussionPanel) {
|
||||
opened = activatePanel(opened, discussionPanel.id);
|
||||
}
|
||||
const newColumn = opened.columns.find(
|
||||
(column) => !state.sidebarLayout.columns.some((current) => current.id === column.id),
|
||||
);
|
||||
const fitted =
|
||||
this.paneWidth >= SIDEBAR_NARROW_BREAKPOINT_PX
|
||||
? (fitSidebarLayout(opened, this.paneWidth, newColumn?.id) ?? opened)
|
||||
: opened;
|
||||
state.updateSidebarLayout(fitted);
|
||||
if (discussionPanel) {
|
||||
state.updateSidebarActivePanel(discussionPanel.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected renderSessionDiscussionAction() {
|
||||
@@ -388,14 +415,12 @@ export abstract class ChatPaneHeader extends ChatPaneContext {
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
const content = this.buildSessionDiscussionContent(state, sessionKey);
|
||||
if (!content) {
|
||||
if (!this.buildSessionDiscussionPanel(state, sessionKey)) {
|
||||
return nothing;
|
||||
}
|
||||
const active =
|
||||
state.sidebarOpen &&
|
||||
state.sidebarContent?.kind === "session-discussion" &&
|
||||
state.sidebarContent.sessionKey === sessionKey;
|
||||
const active = state.sidebarLayout.columns.some((column) =>
|
||||
column.panels.some((panel) => panel.slot === "discussion"),
|
||||
);
|
||||
const label = t(active ? "chat.sessionDiscussion.hide" : "chat.sessionDiscussion.show");
|
||||
return html`
|
||||
<openclaw-tooltip .content=${label}>
|
||||
@@ -404,7 +429,10 @@ export abstract class ChatPaneHeader extends ChatPaneContext {
|
||||
type="button"
|
||||
aria-label=${label}
|
||||
aria-pressed=${String(active)}
|
||||
@click=${() => (active ? state.handleCloseSidebar() : state.handleOpenSidebar(content))}
|
||||
@click=${() =>
|
||||
active
|
||||
? state.updateSidebarLayout(closeSlot(state.sidebarLayout, "discussion"))
|
||||
: this.openSessionDiscussionSlot()}
|
||||
>
|
||||
${icons.messageSquare}
|
||||
</button>
|
||||
|
||||
@@ -82,7 +82,7 @@ function createTestChatPane(params: { client: GatewayBrowserClient; sessions: Se
|
||||
sessionsError: null,
|
||||
sessionsLoading: false,
|
||||
sidebarContent: null,
|
||||
sidebarOpen: false,
|
||||
sidebarLayout: { columns: [] },
|
||||
chatScrollGeneration: 0,
|
||||
chatScrollCommitCleanup: null,
|
||||
handleChatScroll: vi.fn(),
|
||||
|
||||
@@ -417,6 +417,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneReset {
|
||||
this.paneResizeObserver?.disconnect();
|
||||
this.paneResizeObserver = null;
|
||||
this.connectionGeneration += 1;
|
||||
this.sessionDiscussionPanels.clear();
|
||||
this.sessionCompanionHydrationKey = "";
|
||||
this.taskSuggestionsRequestVersion += 1;
|
||||
this.taskSuggestions = [];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
CHAT_DETAIL_FULL_MESSAGE_MAX_CHARS,
|
||||
activeChatRunStartupStatus,
|
||||
areUiSessionKeysEquivalent,
|
||||
buildAgentMainSessionKey,
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
renderChatControls,
|
||||
resolveActiveRunOutputTokens,
|
||||
resolveAssistantAttachmentAuthToken,
|
||||
resolveBoardChatLayoutWidth,
|
||||
resolveChatAgentId,
|
||||
resolveChatAvatarUrl,
|
||||
resolveControlUiFollowUpMode,
|
||||
@@ -45,20 +43,33 @@ import {
|
||||
switchChatModel,
|
||||
switchChatThinkingLevel,
|
||||
t,
|
||||
SIDEBAR_NARROW_BREAKPOINT_PX,
|
||||
activatePanel,
|
||||
closeSlot,
|
||||
detachPanelToColumn,
|
||||
isSidebarRegionCollapsed,
|
||||
mergePanelIntoColumn,
|
||||
sidebarPrimaryWidth,
|
||||
workspaceResultConflictFromPlacement,
|
||||
type BoardViewCallbacks,
|
||||
type ChatProps,
|
||||
type DetailFullMessageResult,
|
||||
type SessionObserverDigest,
|
||||
type SidebarFullMessageRequest,
|
||||
type SidebarSide,
|
||||
type SidebarSlotId,
|
||||
} from "./chat-pane-deps.ts";
|
||||
import { ChatPaneHeaderRender } from "./chat-pane-header-render.ts";
|
||||
import {
|
||||
DETAIL_SIDEBAR_SIDE_MIN_WIDTH,
|
||||
SESSION_RAIL_DOCK_MIN_WIDTH,
|
||||
WORKSPACE_RAIL_MAX_WIDTH,
|
||||
WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH,
|
||||
} from "./chat-pane-shared.ts";
|
||||
import {
|
||||
createSidebarFullMessageLoader,
|
||||
renderSidebarRegion,
|
||||
resolveSidebarLayoutForBoard,
|
||||
restoreHiddenSidebarChat,
|
||||
} from "./chat-pane-sidebar-layout.ts";
|
||||
import { renderChatImageLightbox } from "./components/chat-image-lightbox.ts";
|
||||
|
||||
export class ChatPaneRender extends ChatPaneHeaderRender {
|
||||
override render() {
|
||||
@@ -96,6 +107,12 @@ export class ChatPaneRender extends ChatPaneHeaderRender {
|
||||
? workspaceConflict
|
||||
: undefined;
|
||||
const board = this.resolveBoardView();
|
||||
const sidebarLayout = resolveSidebarLayoutForBoard({
|
||||
board,
|
||||
hasDetail: state.sidebarContent !== null,
|
||||
layout: state.sidebarLayout,
|
||||
paneWidth: this.paneWidth,
|
||||
});
|
||||
const runtimeConfigState = this.context.runtimeConfig.state;
|
||||
const configSnapshot = runtimeConfigState.configSnapshot;
|
||||
const serverQueueMode = resolveControlUiServerQueueMode(configSnapshot?.runtimeConfig, {
|
||||
@@ -168,13 +185,14 @@ export class ChatPaneRender extends ChatPaneHeaderRender {
|
||||
? t("chat.catalog.remoteViewOnly")
|
||||
: t("chat.catalog.unsupportedViewOnly")
|
||||
: null;
|
||||
const chatLayoutWidth = resolveBoardChatLayoutWidth({
|
||||
paneWidth: this.paneWidth,
|
||||
hasBoard: board.hasBoard,
|
||||
face: board.face,
|
||||
dock: board.dock,
|
||||
dockWidth: this.boardChatDockSize.width,
|
||||
});
|
||||
const sidebarChatColumn = sidebarLayout.columns.find((column) =>
|
||||
column.panels.some((panel) => panel.slot === "chat"),
|
||||
);
|
||||
const sidebarRegionCollapsed = isSidebarRegionCollapsed(sidebarLayout, this.paneWidth);
|
||||
const primaryWidth = sidebarPrimaryWidth(sidebarLayout, this.paneWidth);
|
||||
const chatLayoutWidth = sidebarRegionCollapsed
|
||||
? this.paneWidth
|
||||
: (sidebarChatColumn?.width ?? primaryWidth);
|
||||
const sessionWorkspace = createSessionWorkspaceProps(state, {
|
||||
draftScope: this.paneId,
|
||||
narrowLayout: chatLayoutWidth < WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH,
|
||||
@@ -194,13 +212,10 @@ export class ChatPaneRender extends ChatPaneHeaderRender {
|
||||
},
|
||||
});
|
||||
const tasksSideDocked = !backgroundTasks.collapsed && !backgroundTasks.narrowLayout;
|
||||
// Every side-docked rail narrows the room left for the chat + detail
|
||||
// split; bottom strips do not.
|
||||
// Side-docked workspace surfaces narrow the conversation region; bottom
|
||||
// strips do not affect whether the session rail can dock beside it.
|
||||
const sideRailCount = (railSideDocked ? 1 : 0) + (tasksSideDocked ? 1 : 0);
|
||||
const detailSplitWidth = chatLayoutWidth - sideRailCount * WORKSPACE_RAIL_MAX_WIDTH;
|
||||
const sidebarStacked = detailSplitWidth < DETAIL_SIDEBAR_SIDE_MIN_WIDTH;
|
||||
const chatMainWidth =
|
||||
state.sidebarOpen && !sidebarStacked ? detailSplitWidth * state.splitRatio : detailSplitWidth;
|
||||
const chatMainWidth = chatLayoutWidth - sideRailCount * WORKSPACE_RAIL_MAX_WIDTH;
|
||||
const selectedSessionRailMode =
|
||||
this.sessionRailModeSessionKey === state.sessionKey ? this.sessionRailMode : "hidden";
|
||||
const gatewaySnapshot = this.context.gateway.snapshot;
|
||||
@@ -214,6 +229,7 @@ export class ChatPaneRender extends ChatPaneHeaderRender {
|
||||
activeRunIds: selectedSession?.activeRunIds,
|
||||
usageByRun: state.chatRunUsageById,
|
||||
});
|
||||
const loadSidebarFullMessage = createSidebarFullMessageLoader(state, Boolean(catalogKey));
|
||||
const props: ChatProps = {
|
||||
transcript: this.transcript,
|
||||
paneId: this.paneId,
|
||||
@@ -513,38 +529,11 @@ export class ChatPaneRender extends ChatPaneHeaderRender {
|
||||
onSessionSelect: (next) => {
|
||||
this.onPaneSessionChange?.(this.paneId, next);
|
||||
},
|
||||
onLoadSidebarFullMessage: catalogKey
|
||||
? undefined
|
||||
: async (request: SidebarFullMessageRequest): Promise<DetailFullMessageResult | null> => {
|
||||
if (!state.client || !state.connected) {
|
||||
return null;
|
||||
}
|
||||
return state.client.request<DetailFullMessageResult>("chat.message.get", {
|
||||
sessionKey: request.sessionKey,
|
||||
...(request.agentId ? { agentId: request.agentId } : {}),
|
||||
messageId: request.messageId,
|
||||
maxChars: CHAT_DETAIL_FULL_MESSAGE_MAX_CHARS,
|
||||
});
|
||||
},
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
sidebarContent: state.sidebarContent,
|
||||
sidebarStacked,
|
||||
splitRatio: state.splitRatio,
|
||||
canvasPluginSurfaceUrl: state.canvasPluginSurfaceUrl,
|
||||
boardProvider: board.provider,
|
||||
onOpenSidebar: state.handleOpenSidebar,
|
||||
onCloseSidebar: () => {
|
||||
const content = state.sidebarContent;
|
||||
if (content?.kind === "session-discussion") {
|
||||
this.sessionDiscussionOpenUrls.delete(content.sessionKey);
|
||||
}
|
||||
state.handleCloseSidebar();
|
||||
},
|
||||
imageLightbox: state.imageLightbox,
|
||||
onRequestOpenImage: state.beginImageOpen,
|
||||
onOpenImage: state.handleOpenImage,
|
||||
onCloseImage: state.handleCloseImage,
|
||||
onSplitRatioChange: state.handleSplitRatioChange,
|
||||
assistantName: state.assistantName,
|
||||
assistantAvatar: state.assistantAvatar,
|
||||
userId: selfUser?.id ?? null,
|
||||
@@ -560,7 +549,7 @@ export class ChatPaneRender extends ChatPaneHeaderRender {
|
||||
};
|
||||
const chat = renderChat(props);
|
||||
const workboardCardChip = this.resolveWorkboardCardChip(board);
|
||||
const content =
|
||||
const primary =
|
||||
board.hasBoard && board.face === "dashboard"
|
||||
? renderBoardSessionSurface({
|
||||
snapshot: board.snapshot,
|
||||
@@ -576,9 +565,7 @@ export class ChatPaneRender extends ChatPaneHeaderRender {
|
||||
reopenDock: board.reopenDock,
|
||||
dockSize: this.boardChatDockSize,
|
||||
chat,
|
||||
divider: this.renderBoardDivider(
|
||||
board.dock === "hidden" ? board.reopenDock : board.dock,
|
||||
),
|
||||
divider: this.renderBoardDivider("bottom"),
|
||||
canMutate: board.provider.canMutate,
|
||||
canGrant: board.provider.canGrant,
|
||||
callbacks: {
|
||||
@@ -598,6 +585,93 @@ export class ChatPaneRender extends ChatPaneHeaderRender {
|
||||
onDockChange: (dock) => this.handleBoardDockChange(dock),
|
||||
})
|
||||
: chat;
|
||||
const discussion = this.buildSessionDiscussionPanel(state, state.sessionKey.trim());
|
||||
const panelTemplates = {
|
||||
chat,
|
||||
...(state.sidebarContent
|
||||
? {
|
||||
detail: html`<openclaw-chat-detail-panel
|
||||
class="chat-sidebar"
|
||||
.content=${state.sidebarContent}
|
||||
.loadFullMessage=${loadSidebarFullMessage}
|
||||
.canvasPluginSurfaceUrl=${state.canvasPluginSurfaceUrl}
|
||||
.embedSandboxMode=${state.embedSandboxMode}
|
||||
.allowExternalEmbedUrls=${state.allowExternalEmbedUrls}
|
||||
.onOpenWorkspaceFile=${(target: { path: string; line?: number | null }) =>
|
||||
openSessionWorkspaceFile(state, target)}
|
||||
.onRevealInWorkspace=${(path: string) => revealSessionWorkspaceFile(state, path)}
|
||||
.onOpenImage=${(item: Parameters<typeof state.handleOpenImage>[0]) =>
|
||||
state.handleOpenImage(item, state.beginImageOpen())}
|
||||
.embedded=${true}
|
||||
@chat-detail-panel-close=${() => state.handleCloseSidebar()}
|
||||
></openclaw-chat-detail-panel>`,
|
||||
}
|
||||
: {}),
|
||||
...(discussion
|
||||
? {
|
||||
discussion: html`<openclaw-session-discussion
|
||||
.sessionKey=${discussion.sessionKey}
|
||||
.canOpen=${discussion.canOpen}
|
||||
.sourceGeneration=${this.connectionGeneration}
|
||||
.loadInfo=${discussion.loadInfo}
|
||||
.openDiscussion=${discussion.openDiscussion}
|
||||
.onStateChange=${discussion.onStateChange}
|
||||
></openclaw-session-discussion>`,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const sidebarCallbacks = {
|
||||
activatePanel: (panelId: string) => {
|
||||
state.updateSidebarLayout(activatePanel(state.sidebarLayout, panelId));
|
||||
state.updateSidebarActivePanel(panelId);
|
||||
},
|
||||
closeSlot: (slot: SidebarSlotId) => {
|
||||
if (slot === "chat") {
|
||||
this.handleBoardDockChange("hidden");
|
||||
return;
|
||||
}
|
||||
if (slot === "discussion") {
|
||||
this.sessionDiscussionOpenUrls.delete(state.sessionKey.trim());
|
||||
}
|
||||
state.updateSidebarLayout(closeSlot(state.sidebarLayout, slot));
|
||||
},
|
||||
detachPanel: (panelId: string, side: SidebarSide, columnIndex: number) => {
|
||||
const moved = restoreHiddenSidebarChat({
|
||||
activatedPanelId: panelId,
|
||||
movedLayout: detachPanelToColumn(sidebarLayout, panelId, side, columnIndex),
|
||||
renderedLayout: sidebarLayout,
|
||||
storedLayout: state.sidebarLayout,
|
||||
});
|
||||
this.commitSidebarPanelMove(moved, panelId, side, board);
|
||||
},
|
||||
mergePanel: (panelId: string, targetColumnId: string, panelIndex: number) => {
|
||||
const target = sidebarLayout.columns.find((column) => column.id === targetColumnId);
|
||||
const merged = restoreHiddenSidebarChat({
|
||||
activatedPanelId: panelId,
|
||||
movedLayout: mergePanelIntoColumn(sidebarLayout, panelId, targetColumnId, panelIndex),
|
||||
renderedLayout: sidebarLayout,
|
||||
storedLayout: state.sidebarLayout,
|
||||
});
|
||||
if (target) {
|
||||
this.commitSidebarPanelMove(merged, panelId, target.side, board);
|
||||
}
|
||||
},
|
||||
resizeColumn: (columnId: string, width: number) => {
|
||||
this.commitSidebarColumnResize(sidebarLayout, columnId, width);
|
||||
},
|
||||
};
|
||||
const content = renderSidebarRegion({
|
||||
availableWidth: this.paneWidth,
|
||||
callbacks: sidebarCallbacks,
|
||||
discussionOpenUrl: discussion?.openUrl ?? null,
|
||||
focusPanelId: state.sidebarFocusPanelId,
|
||||
focusVersion: state.sidebarFocusVersion,
|
||||
layout: sidebarLayout,
|
||||
narrow: this.paneWidth < SIDEBAR_NARROW_BREAKPOINT_PX,
|
||||
panelTemplates,
|
||||
primary,
|
||||
sessionKey: state.sessionKey,
|
||||
});
|
||||
return html`${this.renderPaneHeader(
|
||||
sessionWorkspace,
|
||||
backgroundTasks,
|
||||
@@ -605,6 +679,9 @@ export class ChatPaneRender extends ChatPaneHeaderRender {
|
||||
Boolean(catalogKey),
|
||||
selectedAgent?.workspace,
|
||||
selectedAgent?.workspaceGit === true,
|
||||
)}${content}${this.renderResetConfirmation()}`;
|
||||
)}${content}${renderChatImageLightbox(
|
||||
state.imageLightbox,
|
||||
state.handleCloseImage,
|
||||
)}${this.renderResetConfirmation()}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,11 +118,7 @@ export const WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH = 800;
|
||||
// Widest the rail's grid column gets; a side-docked rail takes this from the
|
||||
// width available to the chat + detail-panel split.
|
||||
export const WORKSPACE_RAIL_MAX_WIDTH = 280;
|
||||
// .chat-main min-width (312) + divider + .chat-sidebar min-width (300) + slack;
|
||||
// below this the detail panel stacks under the thread.
|
||||
export const DETAIL_SIDEBAR_SIDE_MIN_WIDTH = 680;
|
||||
export const SESSION_RAIL_DOCK_MIN_WIDTH = 1080;
|
||||
|
||||
export const NEW_SESSION_ACTIVE_RUN_MESSAGE =
|
||||
"Start a new thread after the active run or queued messages finish.";
|
||||
export const NEW_SESSION_LIST_LOADING_MESSAGE =
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { html, render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedBoardView } from "./chat-pane-shared.ts";
|
||||
import {
|
||||
renderSidebarRegion,
|
||||
resolveSidebarLayoutForBoard,
|
||||
restoreHiddenSidebarChat,
|
||||
} from "./chat-pane-sidebar-layout.ts";
|
||||
import {
|
||||
closeSlot,
|
||||
detachPanelToColumn,
|
||||
mergePanelIntoColumn,
|
||||
openSlot,
|
||||
} from "./sidebar-layout.ts";
|
||||
|
||||
function board(dock: ResolvedBoardView["dock"], face: ResolvedBoardView["face"] = "dashboard") {
|
||||
return {
|
||||
hasBoard: true,
|
||||
face,
|
||||
dock,
|
||||
} as ResolvedBoardView;
|
||||
}
|
||||
|
||||
describe("chat pane sidebar layout", () => {
|
||||
it("preserves the primary DOM across open, close, and reopen", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const callbacks = {
|
||||
activatePanel: vi.fn(),
|
||||
closeSlot: vi.fn(),
|
||||
detachPanel: vi.fn(),
|
||||
mergePanel: vi.fn(),
|
||||
resizeColumn: vi.fn(),
|
||||
};
|
||||
const renderLayout = async (
|
||||
layout: ReturnType<typeof openSlot> | { columns: [] },
|
||||
narrow = false,
|
||||
) => {
|
||||
render(
|
||||
renderSidebarRegion({
|
||||
availableWidth: narrow ? 620 : 1_400,
|
||||
callbacks,
|
||||
discussionOpenUrl: null,
|
||||
focusPanelId: "",
|
||||
focusVersion: 0,
|
||||
layout,
|
||||
narrow,
|
||||
panelTemplates: { detail: html`<aside>Details</aside>` },
|
||||
primary: html`<main data-primary>Primary</main>`,
|
||||
sessionKey: "agent:main:current",
|
||||
}),
|
||||
container,
|
||||
);
|
||||
};
|
||||
|
||||
await renderLayout({ columns: [] });
|
||||
const primary = container.querySelector("[data-primary]");
|
||||
await renderLayout(openSlot({ columns: [] }, "detail"));
|
||||
expect(container.querySelector("[data-primary]")).toBe(primary);
|
||||
await customElements.whenDefined("openclaw-chat-sidebar-region");
|
||||
await container.querySelector("openclaw-chat-sidebar-region")?.updateComplete;
|
||||
expect(container.querySelector("[data-primary]")).toBe(primary);
|
||||
const rightTab = container.querySelector(".sidebar-region__right-runtime .sidebar-column__tab");
|
||||
expect(rightTab).not.toBeNull();
|
||||
expect(primary!.compareDocumentPosition(rightTab!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
await renderLayout({ columns: [] });
|
||||
expect(container.querySelector("[data-primary]")).toBe(primary);
|
||||
await renderLayout(openSlot({ columns: [] }, "detail"));
|
||||
expect(container.querySelector("[data-primary]")).toBe(primary);
|
||||
await renderLayout(openSlot({ columns: [] }, "detail"), true);
|
||||
await container.querySelector("openclaw-chat-sidebar-region")?.updateComplete;
|
||||
expect(container.querySelector("[data-primary]")).toBe(primary);
|
||||
await renderLayout(openSlot({ columns: [] }, "detail"));
|
||||
await container.querySelector("openclaw-chat-sidebar-region")?.updateComplete;
|
||||
expect(container.querySelector("[data-primary]")).toBe(primary);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("promotes side-docked dashboard chat into the requested side", () => {
|
||||
const layout = resolveSidebarLayoutForBoard({
|
||||
board: board("left"),
|
||||
hasDetail: false,
|
||||
layout: { columns: [] },
|
||||
paneWidth: 1_400,
|
||||
});
|
||||
expect(layout.columns[0]?.side).toBe("left");
|
||||
expect(layout.columns[0]?.panels[0]?.slot).toBe("chat");
|
||||
});
|
||||
|
||||
it("keeps an unmeasured wide shell aligned with the sidebar runtime", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderSidebarRegion({
|
||||
availableWidth: 0,
|
||||
callbacks: {
|
||||
activatePanel: vi.fn(),
|
||||
closeSlot: vi.fn(),
|
||||
detachPanel: vi.fn(),
|
||||
mergePanel: vi.fn(),
|
||||
resizeColumn: vi.fn(),
|
||||
},
|
||||
discussionOpenUrl: null,
|
||||
focusPanelId: "",
|
||||
focusVersion: 0,
|
||||
layout: openSlot({ columns: [] }, "detail"),
|
||||
narrow: false,
|
||||
panelTemplates: { detail: html`<aside>Details</aside>` },
|
||||
primary: html`<main>Primary</main>`,
|
||||
sessionKey: "agent:main:current",
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".sidebar-region--narrow")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps bottom chat outside the sidebar model", () => {
|
||||
const layout = resolveSidebarLayoutForBoard({
|
||||
board: board("bottom"),
|
||||
hasDetail: true,
|
||||
layout: openSlot(openSlot({ columns: [] }, "chat"), "detail"),
|
||||
paneWidth: 1_400,
|
||||
});
|
||||
expect(layout.columns.flatMap((column) => column.panels.map((panel) => panel.slot))).toEqual([
|
||||
"detail",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops stale detail placement when no transient detail is available", () => {
|
||||
const layout = resolveSidebarLayoutForBoard({
|
||||
board: board("hidden", "chat"),
|
||||
hasDetail: false,
|
||||
layout: openSlot({ columns: [] }, "detail"),
|
||||
paneWidth: 1_400,
|
||||
});
|
||||
expect(layout).toEqual({ columns: [] });
|
||||
});
|
||||
|
||||
it("preserves stored chat placement when moving panels in a hidden-chat projection", () => {
|
||||
const stored = openSlot(
|
||||
openSlot(openSlot({ columns: [] }, "chat", "left"), "detail"),
|
||||
"discussion",
|
||||
);
|
||||
const rendered = closeSlot(stored, "chat");
|
||||
const detail = rendered.columns
|
||||
.flatMap((column) => column.panels)
|
||||
.find((panel) => panel.slot === "detail")!;
|
||||
const discussionColumn = rendered.columns.find((column) =>
|
||||
column.panels.some((panel) => panel.slot === "discussion"),
|
||||
)!;
|
||||
const movedProjection = mergePanelIntoColumn(rendered, detail.id, discussionColumn.id, 0);
|
||||
const moved = restoreHiddenSidebarChat({
|
||||
activatedPanelId: detail.id,
|
||||
movedLayout: movedProjection,
|
||||
renderedLayout: rendered,
|
||||
storedLayout: stored,
|
||||
});
|
||||
|
||||
expect(moved.columns.flatMap((column) => column.panels.map((panel) => panel.slot))).toEqual([
|
||||
"chat",
|
||||
"detail",
|
||||
"discussion",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves the stored active chat tab across an unrelated projected move", () => {
|
||||
let stored = openSlot(openSlot(openSlot({ columns: [] }, "chat"), "detail"), "discussion");
|
||||
const chatColumn = stored.columns.find((column) =>
|
||||
column.panels.some((panel) => panel.slot === "chat"),
|
||||
)!;
|
||||
const chatPanel = chatColumn.panels.find((panel) => panel.slot === "chat")!;
|
||||
const detailPanel = stored.columns
|
||||
.flatMap((column) => column.panels)
|
||||
.find((panel) => panel.slot === "detail")!;
|
||||
const discussionPanel = stored.columns
|
||||
.flatMap((column) => column.panels)
|
||||
.find((panel) => panel.slot === "discussion")!;
|
||||
stored = mergePanelIntoColumn(stored, detailPanel.id, chatColumn.id, 1);
|
||||
stored = mergePanelIntoColumn(stored, discussionPanel.id, chatColumn.id, 2);
|
||||
stored.columns.find((column) => column.id === chatColumn.id)!.activePanelId = chatPanel.id;
|
||||
const rendered = closeSlot(stored, "chat");
|
||||
const movedProjection = detachPanelToColumn(rendered, detailPanel.id, "right", 0);
|
||||
|
||||
const moved = restoreHiddenSidebarChat({
|
||||
activatedPanelId: detailPanel.id,
|
||||
movedLayout: movedProjection,
|
||||
renderedLayout: rendered,
|
||||
storedLayout: stored,
|
||||
});
|
||||
|
||||
expect(moved.columns.find((column) => column.id === chatColumn.id)?.activePanelId).toBe(
|
||||
chatPanel.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("refits ordinary chat columns to preserve the primary minimum", () => {
|
||||
const layout = resolveSidebarLayoutForBoard({
|
||||
board: board("hidden", "chat"),
|
||||
hasDetail: true,
|
||||
layout: openSlot(openSlot({ columns: [] }, "detail"), "discussion"),
|
||||
paneWidth: 1_000,
|
||||
});
|
||||
expect(layout.columns.reduce((sum, column) => sum + column.width, 0)).toBe(680);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { ResolvedBoardView } from "./chat-pane-shared.ts";
|
||||
import type {
|
||||
SidebarPanelTemplates,
|
||||
SidebarRegionCallbacks,
|
||||
} from "./components/chat-sidebar-region-types.ts";
|
||||
import type {
|
||||
DetailFullMessageResult,
|
||||
SidebarFullMessageRequest,
|
||||
} from "./components/chat-sidebar.ts";
|
||||
import {
|
||||
closeSlot,
|
||||
detachPanelToColumn,
|
||||
fitSidebarLayout,
|
||||
isSidebarRegionCollapsed,
|
||||
openSlot,
|
||||
type SidebarColumn,
|
||||
type SidebarLayout,
|
||||
type SidebarPanel,
|
||||
} from "./sidebar-layout.ts";
|
||||
|
||||
const DETAIL_FULL_MESSAGE_MAX_CHARS = 500_000;
|
||||
let sidebarRegionLoad: Promise<boolean> | null = null;
|
||||
|
||||
export function renderSidebarRegion(params: {
|
||||
availableWidth: number;
|
||||
callbacks: SidebarRegionCallbacks;
|
||||
discussionOpenUrl: string | null;
|
||||
focusPanelId: string;
|
||||
focusVersion: number;
|
||||
layout: SidebarLayout;
|
||||
narrow: boolean;
|
||||
panelTemplates: SidebarPanelTemplates;
|
||||
primary: TemplateResult;
|
||||
sessionKey: string;
|
||||
}): TemplateResult {
|
||||
const hasPanels = params.layout.columns.some((column) => column.panels.length > 0);
|
||||
if (hasPanels && !customElements.get("openclaw-chat-sidebar-region")) {
|
||||
sidebarRegionLoad ??= import("./components/chat-sidebar-region.runtime.ts").then(
|
||||
() => true,
|
||||
() => {
|
||||
sidebarRegionLoad = null;
|
||||
return false;
|
||||
},
|
||||
);
|
||||
}
|
||||
const availableWidth =
|
||||
params.availableWidth > 0 ? params.availableWidth : Number.POSITIVE_INFINITY;
|
||||
const collapsed = params.narrow || isSidebarRegionCollapsed(params.layout, availableWidth);
|
||||
return html`<div class="sidebar-region ${collapsed && hasPanels ? "sidebar-region--narrow" : ""}">
|
||||
<openclaw-chat-sidebar-region
|
||||
.layout=${params.layout}
|
||||
.panelTemplates=${params.panelTemplates}
|
||||
.panelOpenUrls=${{ discussion: params.discussionOpenUrl }}
|
||||
.callbacks=${params.callbacks}
|
||||
.sessionKey=${params.sessionKey}
|
||||
.focusPanelId=${params.focusPanelId}
|
||||
.focusVersion=${params.focusVersion}
|
||||
.narrow=${params.narrow}
|
||||
.availableWidth=${params.availableWidth}
|
||||
></openclaw-chat-sidebar-region>
|
||||
<div class="sidebar-region__primary">${params.primary}</div>
|
||||
<div class="sidebar-region__right-runtime"></div>
|
||||
<div class="sidebar-region__panels-runtime"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function resolveSidebarLayoutForBoard(params: {
|
||||
board: ResolvedBoardView;
|
||||
hasDetail: boolean;
|
||||
layout: SidebarLayout;
|
||||
paneWidth: number;
|
||||
}): SidebarLayout {
|
||||
let layout = params.hasDetail ? params.layout : closeSlot(params.layout, "detail");
|
||||
const chatSide =
|
||||
params.board.hasBoard &&
|
||||
params.board.face === "dashboard" &&
|
||||
(params.board.dock === "left" || params.board.dock === "right")
|
||||
? params.board.dock
|
||||
: null;
|
||||
if (!chatSide) {
|
||||
layout = closeSlot(layout, "chat");
|
||||
return fitSidebarLayout(layout, params.paneWidth) ?? layout;
|
||||
}
|
||||
const beforeOpen = layout;
|
||||
layout = openSlot(layout, "chat", chatSide);
|
||||
const chatColumn = layout.columns.find((column) =>
|
||||
column.panels.some((panel) => panel.slot === "chat"),
|
||||
);
|
||||
if (chatColumn && chatColumn.side !== chatSide) {
|
||||
const chatPanel = chatColumn.panels.find((panel) => panel.slot === "chat");
|
||||
if (chatPanel) {
|
||||
layout = detachPanelToColumn(layout, chatPanel.id, chatSide, 0);
|
||||
}
|
||||
}
|
||||
const newColumn = layout.columns.find(
|
||||
(column) => !beforeOpen.columns.some((current) => current.id === column.id),
|
||||
);
|
||||
return fitSidebarLayout(layout, params.paneWidth, newColumn?.id) ?? layout;
|
||||
}
|
||||
|
||||
function stableInsertionIndex(order: string[], current: string[], targetId: string): number {
|
||||
const targetIndex = order.indexOf(targetId);
|
||||
for (let index = targetIndex - 1; index >= 0; index -= 1) {
|
||||
const currentIndex = current.indexOf(order[index]!);
|
||||
if (currentIndex >= 0) {
|
||||
return currentIndex + 1;
|
||||
}
|
||||
}
|
||||
for (let index = targetIndex + 1; index < order.length; index += 1) {
|
||||
const currentIndex = current.indexOf(order[index]!);
|
||||
if (currentIndex >= 0) {
|
||||
return currentIndex;
|
||||
}
|
||||
}
|
||||
return Math.max(0, Math.min(targetIndex, current.length));
|
||||
}
|
||||
|
||||
export function restoreHiddenSidebarChat(params: {
|
||||
activatedPanelId: string;
|
||||
movedLayout: SidebarLayout;
|
||||
renderedLayout: SidebarLayout;
|
||||
storedLayout: SidebarLayout;
|
||||
}): SidebarLayout {
|
||||
const renderedHasChat = params.renderedLayout.columns.some((column) =>
|
||||
column.panels.some((panel) => panel.slot === "chat"),
|
||||
);
|
||||
if (renderedHasChat) {
|
||||
return params.movedLayout;
|
||||
}
|
||||
let storedColumn: SidebarColumn | undefined;
|
||||
let storedPanel: SidebarPanel | undefined;
|
||||
for (const column of params.storedLayout.columns) {
|
||||
const chat = column.panels.find((panel) => panel.slot === "chat");
|
||||
if (chat) {
|
||||
storedColumn = column;
|
||||
storedPanel = chat;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!storedColumn || !storedPanel) {
|
||||
return params.movedLayout;
|
||||
}
|
||||
if (
|
||||
params.movedLayout.columns.some((column) =>
|
||||
column.panels.some((panel) => panel.id === storedPanel.id),
|
||||
)
|
||||
) {
|
||||
return params.movedLayout;
|
||||
}
|
||||
const existingColumnIndex = params.movedLayout.columns.findIndex(
|
||||
(column) => column.id === storedColumn.id,
|
||||
);
|
||||
if (existingColumnIndex >= 0) {
|
||||
const columns = [...params.movedLayout.columns];
|
||||
const column = columns[existingColumnIndex]!;
|
||||
const panelIndex = stableInsertionIndex(
|
||||
storedColumn.panels.map((panel) => panel.id),
|
||||
column.panels.map((panel) => panel.id),
|
||||
storedPanel.id,
|
||||
);
|
||||
const panels = [...column.panels];
|
||||
panels.splice(panelIndex, 0, storedPanel);
|
||||
const moveActivatedThisColumn = column.panels.some(
|
||||
(panel) => panel.id === params.activatedPanelId,
|
||||
);
|
||||
columns[existingColumnIndex] = {
|
||||
...column,
|
||||
panels,
|
||||
activePanelId:
|
||||
storedColumn.activePanelId === storedPanel.id && !moveActivatedThisColumn
|
||||
? storedPanel.id
|
||||
: column.activePanelId,
|
||||
};
|
||||
return { columns };
|
||||
}
|
||||
const columns = [...params.movedLayout.columns];
|
||||
const columnIndex = stableInsertionIndex(
|
||||
params.storedLayout.columns.map((column) => column.id),
|
||||
columns.map((column) => column.id),
|
||||
storedColumn.id,
|
||||
);
|
||||
columns.splice(columnIndex, 0, {
|
||||
...storedColumn,
|
||||
panels: [storedPanel],
|
||||
activePanelId: storedPanel.id,
|
||||
});
|
||||
return { columns };
|
||||
}
|
||||
|
||||
export function createSidebarFullMessageLoader(
|
||||
state: { client: GatewayBrowserClient | null; connected: boolean },
|
||||
disabled: boolean,
|
||||
): ((request: SidebarFullMessageRequest) => Promise<DetailFullMessageResult | null>) | null {
|
||||
if (disabled) {
|
||||
return null;
|
||||
}
|
||||
return async (request) => {
|
||||
if (!state.client || !state.connected) {
|
||||
return null;
|
||||
}
|
||||
return state.client.request<DetailFullMessageResult>("chat.message.get", {
|
||||
sessionKey: request.sessionKey,
|
||||
...(request.agentId ? { agentId: request.agentId } : {}),
|
||||
messageId: request.messageId,
|
||||
maxChars: DETAIL_FULL_MESSAGE_MAX_CHARS,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -68,7 +68,7 @@ function createTestChatPane(params: { client: GatewayBrowserClient; sessions: Se
|
||||
sessionsError: null,
|
||||
sessionsLoading: false,
|
||||
sidebarContent: null,
|
||||
sidebarOpen: false,
|
||||
sidebarLayout: { columns: [] },
|
||||
// Minimal scroll host so scheduleChatScroll is a no-op instead of throwing.
|
||||
chatScrollGeneration: 0,
|
||||
chatScrollCommitCleanup: null,
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { html, render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SessionDiscussionInfo } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { SessionCapability } from "../../lib/sessions/index.ts";
|
||||
import { createTestChatPane, type TestChatPane } from "./chat-pane.test-support.ts";
|
||||
import type { SidebarContent } from "./components/chat-sidebar.ts";
|
||||
import "./components/chat-sidebar.ts";
|
||||
import type { SessionDiscussionPanelConfig } from "./components/session-discussion-panel.ts";
|
||||
import "./components/session-discussion-panel.ts";
|
||||
import { openSlot } from "./sidebar-layout.ts";
|
||||
|
||||
type DiscussionTestPane = TestChatPane & {
|
||||
buildSessionDiscussionPanel: (
|
||||
state: ReturnType<typeof createTestChatPane>["state"],
|
||||
sessionKey: string,
|
||||
) => SessionDiscussionPanelConfig | null;
|
||||
probeSessionDiscussion: (sessionKey: string) => Promise<void>;
|
||||
renderSessionDiscussionAction: () => unknown;
|
||||
paneWidth: number;
|
||||
};
|
||||
|
||||
const SESSION_KEY = "agent:main:current";
|
||||
|
||||
function createDiscussionPane(params: {
|
||||
info: SessionDiscussionInfo | Promise<SessionDiscussionInfo>;
|
||||
sidebarOpen?: boolean;
|
||||
detailOpen?: boolean;
|
||||
}) {
|
||||
const request = vi.fn().mockImplementation(async (method: string) => {
|
||||
if (method === "session.discussion.info") {
|
||||
@@ -33,81 +39,85 @@ function createDiscussionPane(params: {
|
||||
(pane.context.gateway.snapshot as { hello: unknown }).hello = {
|
||||
features: { methods: ["session.discussion.info", "session.discussion.open"] },
|
||||
};
|
||||
const handleOpenSidebar = vi.fn((content: SidebarContent) => {
|
||||
state.sidebarContent = content;
|
||||
state.sidebarOpen = true;
|
||||
state.sidebarLayout = params.detailOpen ? openSlot({ columns: [] }, "detail") : { columns: [] };
|
||||
const updateSidebarLayout = vi.fn((layout) => {
|
||||
state.sidebarLayout = layout;
|
||||
});
|
||||
const handleCloseSidebar = vi.fn(() => {
|
||||
state.sidebarOpen = false;
|
||||
});
|
||||
state.handleOpenSidebar = handleOpenSidebar;
|
||||
state.handleCloseSidebar = handleCloseSidebar;
|
||||
state.sidebarOpen = params.sidebarOpen ?? false;
|
||||
return { pane, state, handleOpenSidebar, handleCloseSidebar, request };
|
||||
state.updateSidebarLayout = updateSidebarLayout;
|
||||
return { pane, state, updateSidebarLayout, request };
|
||||
}
|
||||
|
||||
describe("chat pane session discussion auto-show", () => {
|
||||
it("auto-shows the sidebar when the probe reports an open discussion", async () => {
|
||||
const { pane, handleOpenSidebar } = createDiscussionPane({
|
||||
it("auto-shows the discussion slot when the probe reports an open discussion", async () => {
|
||||
const { pane, state, updateSidebarLayout } = createDiscussionPane({
|
||||
info: { state: "open", embedUrl: "https://clack.example/embed/c1" },
|
||||
});
|
||||
|
||||
await pane.probeSessionDiscussion(SESSION_KEY);
|
||||
|
||||
expect(handleOpenSidebar).toHaveBeenCalledTimes(1);
|
||||
const content = handleOpenSidebar.mock.calls[0]?.[0];
|
||||
expect(content?.kind).toBe("session-discussion");
|
||||
expect(content && "sessionKey" in content ? content.sessionKey : null).toBe(SESSION_KEY);
|
||||
expect(updateSidebarLayout).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
state.sidebarLayout.columns.flatMap((column) => column.panels.map((panel) => panel.slot)),
|
||||
).toEqual(["discussion"]);
|
||||
});
|
||||
|
||||
it("shows the reported external URL in the outer sidebar header", async () => {
|
||||
it("keeps the reported external URL with the promoted discussion panel", async () => {
|
||||
const openUrl = "https://clack.example/channels/c1";
|
||||
const { pane, state, handleOpenSidebar } = createDiscussionPane({
|
||||
info: {
|
||||
state: "open",
|
||||
embedUrl: "https://clack.example/embed/c1",
|
||||
openUrl,
|
||||
},
|
||||
const { pane, state } = createDiscussionPane({
|
||||
info: { state: "open", embedUrl: "https://clack.example/embed/c1", openUrl },
|
||||
});
|
||||
|
||||
await pane.probeSessionDiscussion(SESSION_KEY);
|
||||
pane
|
||||
.buildSessionDiscussionPanel(state, SESSION_KEY)
|
||||
?.onStateChange(SESSION_KEY, "open", openUrl);
|
||||
|
||||
const content = handleOpenSidebar.mock.calls[0]?.[0];
|
||||
if (!content || content.kind !== "session-discussion") {
|
||||
throw new Error("expected a session discussion sidebar");
|
||||
}
|
||||
content.onStateChange(SESSION_KEY, "open", openUrl);
|
||||
expect(pane.buildSessionDiscussionPanel(state, SESSION_KEY)?.openUrl).toBe(openUrl);
|
||||
});
|
||||
|
||||
const panel = document.createElement("openclaw-chat-detail-panel") as HTMLElement & {
|
||||
content: SidebarContent;
|
||||
onClose: () => void;
|
||||
updateComplete: Promise<unknown>;
|
||||
it("does not reload discussion info when the pane renders unchanged config twice", async () => {
|
||||
const { pane, state, request } = createDiscussionPane({
|
||||
info: { state: "open", embedUrl: "https://clack.example/embed/c1" },
|
||||
});
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
|
||||
const renderPanel = async () => {
|
||||
const config = pane.buildSessionDiscussionPanel(state, SESSION_KEY)!;
|
||||
render(
|
||||
html`<openclaw-session-discussion
|
||||
.sessionKey=${config.sessionKey}
|
||||
.canOpen=${config.canOpen}
|
||||
.sourceGeneration=${pane.connectionGeneration}
|
||||
.loadInfo=${config.loadInfo}
|
||||
.openDiscussion=${config.openDiscussion}
|
||||
.onStateChange=${config.onStateChange}
|
||||
></openclaw-session-discussion>`,
|
||||
container,
|
||||
);
|
||||
await container.querySelector("openclaw-session-discussion")?.updateComplete;
|
||||
};
|
||||
panel.content = state.sidebarContent as SidebarContent;
|
||||
panel.onClose = vi.fn();
|
||||
document.body.append(panel);
|
||||
await panel.updateComplete;
|
||||
|
||||
const external = panel.querySelector<HTMLAnchorElement>(".sidebar-header a");
|
||||
expect(external?.href).toBe(openUrl);
|
||||
expect(external?.target).toBe("_blank");
|
||||
expect(external?.rel).toBe("noopener");
|
||||
expect(panel.querySelector(".session-discussion__header")).toBeNull();
|
||||
panel.remove();
|
||||
await renderPanel();
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
||||
await renderPanel();
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("does not auto-show for a merely available discussion", async () => {
|
||||
const { pane, handleOpenSidebar } = createDiscussionPane({
|
||||
const { pane, updateSidebarLayout } = createDiscussionPane({
|
||||
info: { state: "available" },
|
||||
});
|
||||
|
||||
await pane.probeSessionDiscussion(SESSION_KEY);
|
||||
|
||||
expect(handleOpenSidebar).not.toHaveBeenCalled();
|
||||
expect(updateSidebarLayout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the header action to open and close the discussion sidebar", async () => {
|
||||
const { pane, handleOpenSidebar, handleCloseSidebar } = createDiscussionPane({
|
||||
it("uses the header action to open and close the discussion slot", async () => {
|
||||
const { pane, state, updateSidebarLayout } = createDiscussionPane({
|
||||
info: { state: "available" },
|
||||
});
|
||||
const container = document.createElement("div");
|
||||
@@ -120,33 +130,76 @@ describe("chat pane session discussion auto-show", () => {
|
||||
expect(action?.ariaLabel).toBe("Show discussion");
|
||||
expect(action?.getAttribute("aria-pressed")).toBe("false");
|
||||
action?.click();
|
||||
expect(handleOpenSidebar).toHaveBeenCalledTimes(1);
|
||||
expect(updateSidebarLayout).toHaveBeenCalledTimes(1);
|
||||
|
||||
render(pane.renderSessionDiscussionAction(), container);
|
||||
action = container.querySelector<HTMLButtonElement>(".chat-session-discussion-toggle");
|
||||
expect(action?.ariaLabel).toBe("Hide discussion");
|
||||
expect(action?.getAttribute("aria-pressed")).toBe("true");
|
||||
action?.click();
|
||||
expect(handleCloseSidebar).toHaveBeenCalledTimes(1);
|
||||
expect(handleOpenSidebar).toHaveBeenCalledTimes(1);
|
||||
expect(state.sidebarLayout.columns).toEqual([]);
|
||||
expect(updateSidebarLayout).toHaveBeenCalledTimes(2);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("does not steal a sidebar that is already open", async () => {
|
||||
const { pane, handleOpenSidebar } = createDiscussionPane({
|
||||
it("opens beside an existing detail slot without stealing it", async () => {
|
||||
const { pane, state } = createDiscussionPane({
|
||||
info: { state: "open", embedUrl: "https://clack.example/embed/c1" },
|
||||
sidebarOpen: true,
|
||||
detailOpen: true,
|
||||
});
|
||||
|
||||
await pane.probeSessionDiscussion(SESSION_KEY);
|
||||
|
||||
expect(handleOpenSidebar).not.toHaveBeenCalled();
|
||||
expect(
|
||||
state.sidebarLayout.columns.flatMap((column) => column.panels.map((panel) => panel.slot)),
|
||||
).toEqual(["detail", "discussion"]);
|
||||
});
|
||||
|
||||
it("opens as a collapsed tab when two columns cannot fit side by side", async () => {
|
||||
const { pane, state } = createDiscussionPane({
|
||||
info: { state: "open", embedUrl: "https://clack.example/embed/c1" },
|
||||
detailOpen: true,
|
||||
});
|
||||
pane.paneWidth = 700;
|
||||
|
||||
await pane.probeSessionDiscussion(SESSION_KEY);
|
||||
|
||||
expect(
|
||||
state.sidebarLayout.columns.flatMap((column) => column.panels.map((panel) => panel.slot)),
|
||||
).toEqual(["detail", "discussion"]);
|
||||
});
|
||||
|
||||
it("ignores a stale none callback after switching sessions", async () => {
|
||||
const { pane, state } = createDiscussionPane({
|
||||
info: { state: "open", embedUrl: "https://clack.example/embed/c1" },
|
||||
});
|
||||
await pane.probeSessionDiscussion(SESSION_KEY);
|
||||
const stalePanel = pane.buildSessionDiscussionPanel(state, SESSION_KEY);
|
||||
state.sessionKey = "agent:main:other";
|
||||
state.sidebarLayout = openSlot({ columns: [] }, "discussion");
|
||||
|
||||
stalePanel?.onStateChange(SESSION_KEY, "none", null);
|
||||
|
||||
expect(state.sidebarLayout.columns[0]?.panels[0]?.slot).toBe("discussion");
|
||||
});
|
||||
|
||||
it("preserves discussion placement across a reconnect", async () => {
|
||||
const { pane, state } = createDiscussionPane({ info: { state: "available" } });
|
||||
state.sidebarLayout = openSlot({ columns: [] }, "discussion");
|
||||
|
||||
pane.applyGatewaySnapshot({
|
||||
...pane.context.gateway.snapshot,
|
||||
phase: "reconnecting",
|
||||
hello: null,
|
||||
});
|
||||
|
||||
expect(state.sidebarLayout.columns[0]?.panels[0]?.slot).toBe("discussion");
|
||||
});
|
||||
|
||||
it("does not auto-show when the pane switched sessions before the probe resolved", async () => {
|
||||
let resolveInfo!: (value: SessionDiscussionInfo) => void;
|
||||
const { pane, state, handleOpenSidebar } = createDiscussionPane({
|
||||
const { pane, state, updateSidebarLayout } = createDiscussionPane({
|
||||
info: new Promise<SessionDiscussionInfo>((resolve) => {
|
||||
resolveInfo = resolve;
|
||||
}),
|
||||
@@ -157,6 +210,6 @@ describe("chat pane session discussion auto-show", () => {
|
||||
resolveInfo({ state: "open", embedUrl: "https://clack.example/embed/c1" });
|
||||
await probe;
|
||||
|
||||
expect(handleOpenSidebar).not.toHaveBeenCalled();
|
||||
expect(updateSidebarLayout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,7 +169,9 @@ export function createTestChatPane(params: {
|
||||
sessionsError: null,
|
||||
sessionsLoading: false,
|
||||
sidebarContent: null,
|
||||
sidebarOpen: false,
|
||||
sidebarFocusPanelId: "",
|
||||
sidebarFocusVersion: 0,
|
||||
sidebarLayout: { columns: [] },
|
||||
// Minimal scroll host so scheduleChatScroll is a no-op instead of throwing.
|
||||
chatScrollGeneration: 0,
|
||||
chatScrollCommitCleanup: null,
|
||||
@@ -178,6 +180,13 @@ export function createTestChatPane(params: {
|
||||
resetToolStream: vi.fn(),
|
||||
renderLifecycle: { afterCommit: () => () => {}, invalidate: () => {} },
|
||||
} as unknown as ChatPageHost;
|
||||
state.updateSidebarLayout = (layout) => {
|
||||
state.sidebarLayout = layout;
|
||||
};
|
||||
state.updateSidebarActivePanel = (panelId) => {
|
||||
state.sidebarFocusPanelId = panelId;
|
||||
state.sidebarFocusVersion += 1;
|
||||
};
|
||||
pane.context = createSessionContext(params.client, params.sessions);
|
||||
pane.state = state;
|
||||
pane.connectedClient = params.client;
|
||||
|
||||
@@ -27,6 +27,7 @@ import { createBackgroundTasksProps } from "./components/chat-background-tasks.t
|
||||
import { createSessionWorkspaceProps } from "./components/chat-session-workspace.ts";
|
||||
import type { SidebarContent } from "./components/chat-sidebar.ts";
|
||||
import { cacheChatSessionSnapshot, type ChatMessageCache } from "./session-message-cache.ts";
|
||||
import { openSlot } from "./sidebar-layout.ts";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@@ -595,7 +596,7 @@ describe("chat pane keyboard shortcuts", () => {
|
||||
pane.active = true;
|
||||
state.connected = false;
|
||||
state.sidebarContent = canvasContent;
|
||||
state.sidebarOpen = true;
|
||||
state.sidebarLayout = openSlot({ columns: [] }, "detail");
|
||||
|
||||
expect(createSessionWorkspaceProps(state).collapsed).toBe(true);
|
||||
|
||||
@@ -603,14 +604,14 @@ describe("chat pane keyboard shortcuts", () => {
|
||||
|
||||
expect(expandEvent.defaultPrevented).toBe(true);
|
||||
expect(createSessionWorkspaceProps(state).collapsed).toBe(false);
|
||||
expect(state.sidebarOpen).toBe(true);
|
||||
expect(state.sidebarLayout.columns[0]?.panels[0]?.slot).toBe("detail");
|
||||
expect(state.sidebarContent).toBe(canvasContent);
|
||||
|
||||
const collapseEvent = dispatchSidebarShortcut(pane);
|
||||
|
||||
expect(collapseEvent.defaultPrevented).toBe(true);
|
||||
expect(createSessionWorkspaceProps(state).collapsed).toBe(true);
|
||||
expect(state.sidebarOpen).toBe(true);
|
||||
expect(state.sidebarLayout.columns[0]?.panels[0]?.slot).toBe("detail");
|
||||
expect(state.sidebarContent).toBe(canvasContent);
|
||||
|
||||
const mainSidebarEvent = dispatchSidebarShortcut(pane, false);
|
||||
|
||||
@@ -1839,40 +1839,35 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("stacks the detail sidebar below the thread in a narrow pane", async () => {
|
||||
it("collapses sidebar columns into one tabbed column below the pane breakpoint", async () => {
|
||||
const page = await openBrowserPage(900, 700);
|
||||
try {
|
||||
// A 620px pane inside a wide viewport: chat-pane sets the stacked class
|
||||
// when the pane cannot fit chat + detail panel side by side.
|
||||
await page.setContent(
|
||||
`<!doctype html><html><head><style>${readUiCss()}</style></head><body>
|
||||
<div style="width: 620px; height: 600px; display: flex;">
|
||||
<div class="chat-split-container chat-split-container--open chat-split-container--stacked">
|
||||
<div class="chat-main" style="flex: 0 1 60%">
|
||||
<div class="chat-thread" role="log">
|
||||
<div class="chat-thread-inner">
|
||||
<div class="chat-group assistant">
|
||||
<div class="chat-avatar assistant">A</div>
|
||||
<div class="chat-group-messages">
|
||||
<div class="chat-bubble"><div class="chat-text">Stacked layout keeps the thread readable.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-region sidebar-region--narrow">
|
||||
<main class="sidebar-region__primary">Primary chat</main>
|
||||
<section class="sidebar-column sidebar-column--collapsed">
|
||||
<div class="sidebar-column__header">
|
||||
<div class="sidebar-column__tabs">
|
||||
<button class="sidebar-column__tab" aria-selected="true">Details</button>
|
||||
<button class="sidebar-column__tab" aria-selected="false">Discussion</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="chat-sidebar"><div class="sidebar-panel">Detail panel</div></section>
|
||||
<div class="sidebar-column__body">Active detail panel</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>`,
|
||||
);
|
||||
|
||||
await expectNoHorizontalOverflow(page);
|
||||
const main = await getRect(page, ".chat-main");
|
||||
const sidebar = await getRect(page, ".chat-sidebar");
|
||||
expect(sidebar.top).toBeGreaterThanOrEqual(main.bottom - 1);
|
||||
expect(Math.abs(sidebar.width - main.width)).toBeLessThanOrEqual(1);
|
||||
const primary = await getRect(page, ".sidebar-region__primary");
|
||||
const sidebar = await getRect(page, ".sidebar-column--collapsed");
|
||||
expect(sidebar.top).toBeGreaterThanOrEqual(primary.bottom - 1);
|
||||
expect(Math.abs(sidebar.width - primary.width)).toBeLessThanOrEqual(1);
|
||||
expect(sidebar.width).toBeGreaterThanOrEqual(618);
|
||||
expect(sidebar.height).toBeGreaterThanOrEqual(160);
|
||||
expect(await page.locator(".sidebar-column__tab").count()).toBe(2);
|
||||
} finally {
|
||||
await closeBrowserPage(page);
|
||||
}
|
||||
|
||||
@@ -395,7 +395,6 @@ function makeHost(overrides?: MakeHostOverrides): TestChatHost | TestChatHostWit
|
||||
chatShowToolCalls: next.chatShowToolCalls,
|
||||
chatPersistCommentary: next.chatPersistCommentary,
|
||||
chatSendShortcut: next.chatSendShortcut,
|
||||
splitRatio: next.splitRatio,
|
||||
});
|
||||
}),
|
||||
...hostOverrides,
|
||||
|
||||
@@ -204,7 +204,7 @@ describe("ChatSessionRailElement", () => {
|
||||
});
|
||||
|
||||
async function mount(overrides: Partial<ChatSessionRailElement> = {}) {
|
||||
const element = new ChatSessionRailElement();
|
||||
const element = document.createElement("openclaw-chat-session-rail") as ChatSessionRailElement;
|
||||
element.sessionKey = "agent:main:run";
|
||||
element.digest = digest();
|
||||
element.running = true;
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "./inpu
|
||||
import type { RenderLifecycle } from "./render-lifecycle.ts";
|
||||
import type { PendingChatAbort } from "./run-lifecycle.ts";
|
||||
import type { ChatMessageCache } from "./session-message-cache.ts";
|
||||
import type { SidebarLayout } from "./sidebar-layout.ts";
|
||||
import type {
|
||||
CompactionStatus,
|
||||
FallbackStatus,
|
||||
@@ -115,11 +116,13 @@ export type ChatPageHost = ChatHost &
|
||||
chatIsProgrammaticScroll: boolean;
|
||||
chatProgrammaticScrollTarget: number;
|
||||
chatScrollToEnd?: (options: { behavior?: ScrollBehavior }) => void;
|
||||
sidebarOpen: boolean;
|
||||
sidebarLayout: SidebarLayout;
|
||||
sidebarContent: SidebarContent | null;
|
||||
sidebarFocusPanelId: string;
|
||||
sidebarFocusVersion: number;
|
||||
updateSidebarActivePanel: (panelId: string) => void;
|
||||
imageLightbox: ImageLightboxItem | null;
|
||||
imageLightboxRequestVersion: number;
|
||||
splitRatio: number;
|
||||
querySelector: (selectors: string) => Element | null;
|
||||
renderLifecycle: RenderLifecycle;
|
||||
onModelChanged: () => Promise<void> | void;
|
||||
@@ -142,10 +145,10 @@ export type ChatPageHost = ChatHost &
|
||||
retryQueuedChatMessage: (id: string) => Promise<void>;
|
||||
steerQueuedChatMessage: (id: string) => Promise<void>;
|
||||
handleCloseSidebar: () => void;
|
||||
updateSidebarLayout: (layout: SidebarLayout) => void;
|
||||
beginImageOpen: () => number;
|
||||
handleOpenImage: (item: ImageLightboxItem, requestVersion?: number) => void;
|
||||
handleCloseImage: () => void;
|
||||
handleSplitRatioChange: (ratio: number) => void;
|
||||
announceSessionSwitch?: (sessionKey: string, label: string) => void;
|
||||
createChatSession?: () => Promise<boolean>;
|
||||
confirmConversationReset?: () => Promise<boolean>;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fetchAssistantIdentity } from "../../app/assistant-identity.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { loadLocalUserIdentity, loadSettings, patchSettings } from "../../app/settings.ts";
|
||||
import { resolveSafeExternalUrl } from "../../lib/open-external-url.ts";
|
||||
import { canonicalUiSessionKeyForPersistence } from "../../lib/sessions/session-key.ts";
|
||||
import { removeQueuedMessage } from "./chat-queue.ts";
|
||||
import { attachChatRealtimeActions, createInitialChatRealtimeState } from "./chat-realtime.ts";
|
||||
import {
|
||||
@@ -22,9 +23,22 @@ import type { RenderLifecycle } from "./render-lifecycle.ts";
|
||||
import { handleAbortChat } from "./run-lifecycle.ts";
|
||||
import { handleChatScroll, resetChatScroll, scheduleChatScroll } from "./scroll.ts";
|
||||
import type { ChatMessageCache } from "./session-message-cache.ts";
|
||||
import {
|
||||
updateSidebarSessionActivePanel,
|
||||
updateSidebarSessionLayout,
|
||||
} from "./sidebar-layout-persistence.ts";
|
||||
import {
|
||||
SIDEBAR_NARROW_BREAKPOINT_PX,
|
||||
activatePanel,
|
||||
closeSlot,
|
||||
fitSidebarLayout,
|
||||
normalizeSidebarLayout,
|
||||
openSlot,
|
||||
} from "./sidebar-layout.ts";
|
||||
import { resetToolStream } from "./tool-stream.ts";
|
||||
|
||||
type ChatPageElement = {
|
||||
getBoundingClientRect?: () => DOMRect;
|
||||
querySelector: (selectors: string) => Element | null;
|
||||
};
|
||||
|
||||
@@ -81,6 +95,10 @@ export function createPageState(
|
||||
chatMessagesBySession: ChatMessageCache = new Map(),
|
||||
): ChatPageHost {
|
||||
const settings = loadSettings();
|
||||
const sidebarSessionKey = canonicalUiSessionKeyForPersistence(
|
||||
{ agentsList: context.agents.state.agentsList, hello: context.gateway?.snapshot.hello },
|
||||
settings.sessionKey,
|
||||
);
|
||||
const identity = loadLocalUserIdentity();
|
||||
const appConfig = context.config.current;
|
||||
const state = {
|
||||
@@ -196,11 +214,12 @@ export function createPageState(
|
||||
chatFollowLocked: false,
|
||||
chatIsProgrammaticScroll: false,
|
||||
chatProgrammaticScrollTarget: 0,
|
||||
sidebarOpen: false,
|
||||
sidebarLayout: normalizeSidebarLayout(settings.sidebarSessionLayouts?.[sidebarSessionKey]),
|
||||
sidebarContent: null,
|
||||
sidebarFocusPanelId: settings.sidebarSessionActivePanels?.[sidebarSessionKey] ?? "",
|
||||
sidebarFocusVersion: 0,
|
||||
imageLightbox: null,
|
||||
imageLightboxRequestVersion: 0,
|
||||
splitRatio: settings.splitRatio,
|
||||
toolStreamById: new Map(),
|
||||
toolStreamOrder: [],
|
||||
toolStreamSyncTimer: null,
|
||||
@@ -230,9 +249,7 @@ export function createPageState(
|
||||
chatShowToolCalls: next.chatShowToolCalls,
|
||||
chatPersistCommentary: next.chatPersistCommentary,
|
||||
chatSendShortcut: next.chatSendShortcut,
|
||||
splitRatio: next.splitRatio,
|
||||
});
|
||||
state.splitRatio = state.settings.splitRatio;
|
||||
renderLifecycle.invalidate();
|
||||
};
|
||||
state.setChatViewMenuOpen = (open, options) => {
|
||||
@@ -276,15 +293,59 @@ export function createPageState(
|
||||
await steerQueuedChatMessage(state, id);
|
||||
renderLifecycle.invalidate();
|
||||
};
|
||||
state.handleOpenSidebar = (content) => {
|
||||
state.sidebarContent = content;
|
||||
state.sidebarOpen = true;
|
||||
state.updateSidebarLayout = (layout) => {
|
||||
const normalized = normalizeSidebarLayout(layout);
|
||||
state.sidebarLayout = normalized;
|
||||
state.settings = patchSettings({
|
||||
sidebarSessionLayouts: updateSidebarSessionLayout(
|
||||
loadSettings().sidebarSessionLayouts,
|
||||
canonicalUiSessionKeyForPersistence(state, state.sessionKey),
|
||||
normalized,
|
||||
),
|
||||
});
|
||||
renderLifecycle.invalidate();
|
||||
};
|
||||
state.handleCloseSidebar = () => {
|
||||
state.sidebarOpen = false;
|
||||
state.updateSidebarActivePanel = (panelId) => {
|
||||
const normalizedPanelId = panelId.trim();
|
||||
if (!normalizedPanelId) {
|
||||
return;
|
||||
}
|
||||
state.sidebarFocusPanelId = normalizedPanelId;
|
||||
state.sidebarFocusVersion += 1;
|
||||
state.settings = patchSettings({
|
||||
sidebarSessionActivePanels: updateSidebarSessionActivePanel(
|
||||
loadSettings().sidebarSessionActivePanels,
|
||||
canonicalUiSessionKeyForPersistence(state, state.sessionKey),
|
||||
normalizedPanelId,
|
||||
),
|
||||
});
|
||||
renderLifecycle.invalidate();
|
||||
};
|
||||
state.handleOpenSidebar = (content) => {
|
||||
let opened = openSlot(state.sidebarLayout, "detail", "right");
|
||||
const detailPanel = opened.columns
|
||||
.flatMap((column) => column.panels)
|
||||
.find((panel) => panel.slot === "detail");
|
||||
if (detailPanel) {
|
||||
opened = activatePanel(opened, detailPanel.id);
|
||||
}
|
||||
const newColumn = opened.columns.find(
|
||||
(column) => !state.sidebarLayout.columns.some((current) => current.id === column.id),
|
||||
);
|
||||
const availableWidth = page.getBoundingClientRect?.().width ?? 0;
|
||||
const fitted =
|
||||
availableWidth > 0 && availableWidth >= SIDEBAR_NARROW_BREAKPOINT_PX
|
||||
? (fitSidebarLayout(opened, availableWidth, newColumn?.id) ?? opened)
|
||||
: opened;
|
||||
state.sidebarContent = content;
|
||||
state.updateSidebarLayout(fitted);
|
||||
if (detailPanel) {
|
||||
state.updateSidebarActivePanel(detailPanel.id);
|
||||
}
|
||||
};
|
||||
state.handleCloseSidebar = () => {
|
||||
state.updateSidebarLayout(closeSlot(state.sidebarLayout, "detail"));
|
||||
};
|
||||
state.beginImageOpen = () => {
|
||||
const requestVersion = invalidateImageLightbox(state);
|
||||
renderLifecycle.invalidate();
|
||||
@@ -310,9 +371,5 @@ export function createPageState(
|
||||
invalidateImageLightbox(state);
|
||||
renderLifecycle.invalidate();
|
||||
};
|
||||
state.handleSplitRatioChange = (ratio) => {
|
||||
const next = Math.max(0.4, Math.min(0.7, ratio));
|
||||
state.applySettings({ ...state.settings, splitRatio: next });
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { loadLocalAssistantIdentity } from "../../app/assistant-identity.ts";
|
||||
import { patchSettings } from "../../app/settings.ts";
|
||||
import { loadSettings, patchSettings } from "../../app/settings.ts";
|
||||
import { isRenderableControlUiAvatarUrl } from "../../lib/avatar.ts";
|
||||
import type { ChatQueueItem } from "../../lib/chat/chat-types.ts";
|
||||
import { scopedAgentParamsForSession } from "../../lib/sessions/index.ts";
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DEFAULT_MAIN_KEY,
|
||||
areUiSessionKeysEquivalent,
|
||||
buildAgentMainSessionKey,
|
||||
canonicalUiSessionKeyForPersistence,
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
resolveUiDefaultAgentId,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
readChatSessionSnapshot,
|
||||
type ChatSessionSnapshot,
|
||||
} from "./session-message-cache.ts";
|
||||
import { normalizeSidebarLayout } from "./sidebar-layout.ts";
|
||||
import { clearAuthoritativeTerminal } from "./terminal-message-identity.ts";
|
||||
|
||||
let lastChatComposerMemoryFallbackSequence = 0;
|
||||
@@ -241,9 +243,14 @@ export function resetChatStateForRouteSession(
|
||||
saveChatMessagesForSession(state, previousSessionKey);
|
||||
const snapshot = restoreChatMessagesForSession(state, sessionKey);
|
||||
state.sessionKey = sessionKey;
|
||||
if (state.sidebarContent?.kind === "session-discussion") {
|
||||
state.sidebarContent = { ...state.sidebarContent, sessionKey };
|
||||
}
|
||||
state.sidebarContent = null;
|
||||
const sidebarSessionKey = canonicalUiSessionKeyForPersistence(state, sessionKey);
|
||||
const sidebarSettings = loadSettings();
|
||||
state.sidebarLayout = normalizeSidebarLayout(
|
||||
sidebarSettings.sidebarSessionLayouts?.[sidebarSessionKey],
|
||||
);
|
||||
state.sidebarFocusPanelId = sidebarSettings.sidebarSessionActivePanels?.[sidebarSessionKey] ?? "";
|
||||
state.sidebarFocusVersion += 1;
|
||||
invalidateImageLightbox(state);
|
||||
state.selectedChatSessionArchived =
|
||||
state.sessionsResult?.sessions.some(
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
storedChatOutboxScopeKey,
|
||||
} from "./composer-persistence.ts";
|
||||
import { scheduleControlUiAfterPaint } from "./performance.ts";
|
||||
import { openSlot } from "./sidebar-layout.ts";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(assistantIdentity, "loadLocalAssistantIdentity").mockReturnValue({
|
||||
@@ -697,6 +698,16 @@ describe("route composer fallback", () => {
|
||||
expect(state.imageLightbox).toBeNull();
|
||||
});
|
||||
|
||||
it("clears transient detail content on a route switch", () => {
|
||||
const { state } = createRouteState("");
|
||||
state.sidebarContent = { kind: "markdown", content: "First session detail" };
|
||||
state.sidebarLayout = openSlot({ columns: [] }, "detail");
|
||||
|
||||
resetChatStateForRouteSession(state, "agent:main:second");
|
||||
|
||||
expect(state.sidebarContent).toBeNull();
|
||||
});
|
||||
|
||||
it("restores one atomic history snapshot when returning to a session", () => {
|
||||
vi.stubGlobal("sessionStorage", createStorageMock());
|
||||
const { state } = createRouteState("");
|
||||
|
||||
@@ -436,7 +436,6 @@ function createChatHeaderState(
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "claw",
|
||||
themeMode: "dark",
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 280,
|
||||
sidebarEntries: [],
|
||||
@@ -652,9 +651,6 @@ function createChatProps(
|
||||
error: null,
|
||||
runError: null,
|
||||
sessions: null,
|
||||
sidebarOpen: false,
|
||||
sidebarContent: null,
|
||||
splitRatio: 0.6,
|
||||
canvasPluginSurfaceUrl: null,
|
||||
embedSandboxMode: "scripts",
|
||||
allowExternalEmbedUrls: false,
|
||||
@@ -691,8 +687,6 @@ function createChatProps(
|
||||
onNavigateToAgent: () => undefined,
|
||||
onSessionSelect: () => undefined,
|
||||
onOpenSidebar: () => undefined,
|
||||
onCloseSidebar: () => undefined,
|
||||
onSplitRatioChange: () => undefined,
|
||||
onChatScroll: () => undefined,
|
||||
basePath: "",
|
||||
...overrides,
|
||||
@@ -1659,26 +1653,7 @@ describe("chat composer workbench", () => {
|
||||
expect(container.querySelector('button[aria-label="Thread workspace"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("stacks the detail sidebar under the thread with a horizontal divider on narrow panes", () => {
|
||||
const sidebarProps = {
|
||||
sidebarOpen: true,
|
||||
sidebarContent: { kind: "markdown", content: "Stacked detail" } as const,
|
||||
onCloseSidebar: () => undefined,
|
||||
};
|
||||
const wide = renderChatView(sidebarProps);
|
||||
const wideContainer = wide.querySelector(".chat-split-container");
|
||||
expect(wideContainer?.classList.contains("chat-split-container--open")).toBe(true);
|
||||
expect(wideContainer?.classList.contains("chat-split-container--stacked")).toBe(false);
|
||||
// Attribute reflection is async; the property binding lands synchronously.
|
||||
expect(wide.querySelector("resizable-divider")?.orientation).toBe("vertical");
|
||||
|
||||
const stacked = renderChatView({ ...sidebarProps, sidebarStacked: true });
|
||||
const stackedContainer = stacked.querySelector(".chat-split-container");
|
||||
expect(stackedContainer?.classList.contains("chat-split-container--stacked")).toBe(true);
|
||||
expect(stacked.querySelector("resizable-divider")?.orientation).toBe("horizontal");
|
||||
});
|
||||
|
||||
it("opens inline Markdown images and renders the active lightbox", () => {
|
||||
it("opens inline Markdown images", () => {
|
||||
const onOpenImage = vi.fn();
|
||||
const src = "data:image/png;base64,cG5n";
|
||||
const container = renderChatView({ onOpenImage });
|
||||
@@ -1701,64 +1676,6 @@ describe("chat composer workbench", () => {
|
||||
fallbackTrigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(openSpy).toHaveBeenCalledWith(src, "_blank", "noopener,noreferrer");
|
||||
openSpy.mockRestore();
|
||||
|
||||
const onCloseImage = vi.fn();
|
||||
const lightboxContainer = renderChatView({
|
||||
imageLightbox: { src, title: "Artifact preview" },
|
||||
onCloseImage,
|
||||
});
|
||||
const lightbox = lightboxContainer.querySelector("openclaw-image-lightbox");
|
||||
expect(lightbox?.src).toBe(src);
|
||||
expect(lightbox?.title).toBe("Artifact preview");
|
||||
lightbox?.dispatchEvent(new CustomEvent("image-lightbox-close", { bubbles: true }));
|
||||
expect(onCloseImage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps lightbox Escape from clearing the pending reply", () => {
|
||||
const onClearReply = vi.fn();
|
||||
const container = renderChatView({
|
||||
replyTarget: { messageId: "reply-1", text: "Keep this reply" },
|
||||
onClearReply,
|
||||
imageLightbox: {
|
||||
src: "data:image/png;base64,cG5n",
|
||||
title: "Artifact preview",
|
||||
},
|
||||
onCloseImage: vi.fn(),
|
||||
});
|
||||
const lightbox = container.querySelector("openclaw-image-lightbox");
|
||||
|
||||
lightbox?.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, composed: true }),
|
||||
);
|
||||
|
||||
expect(onClearReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens sidebar Markdown images once", async () => {
|
||||
const onOpenImage = vi.fn();
|
||||
const container = renderChatView({
|
||||
sidebarOpen: true,
|
||||
sidebarContent: {
|
||||
kind: "markdown",
|
||||
content: "",
|
||||
},
|
||||
onCloseSidebar: vi.fn(),
|
||||
onOpenImage,
|
||||
});
|
||||
document.body.append(container);
|
||||
const panel = container.querySelector("openclaw-chat-detail-panel") as
|
||||
| (Element & { updateComplete: Promise<unknown> })
|
||||
| null;
|
||||
await panel?.updateComplete;
|
||||
|
||||
panel?.querySelector<HTMLButtonElement>(".markdown-inline-image-button")?.click();
|
||||
|
||||
expect(onOpenImage).toHaveBeenCalledOnce();
|
||||
expect(onOpenImage).toHaveBeenCalledWith({
|
||||
src: "data:image/png;base64,cG5n",
|
||||
title: "Preview",
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("forces the workspace rail to the bottom dock and drops side-dock controls on narrow panes", () => {
|
||||
|
||||
@@ -41,25 +41,15 @@ import {
|
||||
type BackgroundTasksProps,
|
||||
} from "./components/chat-background-tasks.ts";
|
||||
import { isChatRunWorking, renderChatComposer } from "./components/chat-composer.ts";
|
||||
import {
|
||||
inlineChatImageFromEvent,
|
||||
openInlineChatImage,
|
||||
renderChatImageLightbox,
|
||||
} from "./components/chat-image-lightbox.ts";
|
||||
import { inlineChatImageFromEvent, openInlineChatImage } from "./components/chat-image-lightbox.ts";
|
||||
import { renderChatPullRequests } from "./components/chat-pull-requests.ts";
|
||||
import { renderChatResizableDivider } from "./components/chat-resizable-divider.ts";
|
||||
import "./components/chat-sidebar.ts";
|
||||
import type { SessionRailMode } from "./components/chat-session-rail.ts";
|
||||
import { renderChatSessionSuggestions } from "./components/chat-session-suggestions.ts";
|
||||
import {
|
||||
renderSessionWorkspaceRail,
|
||||
type SessionWorkspaceProps,
|
||||
} from "./components/chat-session-workspace.ts";
|
||||
import type {
|
||||
DetailFullMessageResult,
|
||||
SidebarContent,
|
||||
SidebarFullMessageRequest,
|
||||
} from "./components/chat-sidebar.ts";
|
||||
import type { SidebarContent } from "./components/chat-sidebar.ts";
|
||||
import { renderChatSwarmProgress } from "./components/chat-swarm-progress.ts";
|
||||
import { renderChatTaskSuggestions } from "./components/chat-task-suggestions.ts";
|
||||
import {
|
||||
@@ -169,15 +159,6 @@ export type ChatProps = {
|
||||
sessionHost?: UiSessionDefaultsHost | null;
|
||||
providerUsage?: ProviderUsageDisplayProps;
|
||||
focusMode?: boolean;
|
||||
onLoadSidebarFullMessage?: (
|
||||
request: SidebarFullMessageRequest,
|
||||
) => Promise<DetailFullMessageResult | null | undefined>;
|
||||
sidebarOpen?: boolean;
|
||||
sidebarContent?: SidebarContent | null;
|
||||
/** Pane too narrow for side-by-side chat + detail panel: stack them
|
||||
* vertically instead (the divider flips to a horizontal handle). */
|
||||
sidebarStacked?: boolean;
|
||||
splitRatio?: number;
|
||||
canvasPluginSurfaceUrl?: string | null;
|
||||
boardProvider?: BoardProvider;
|
||||
embedSandboxMode?: EmbedSandboxMode;
|
||||
@@ -197,10 +178,8 @@ export type ChatProps = {
|
||||
getAttachments?: () => ChatAttachment[];
|
||||
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
|
||||
onAssistantAttachmentLoaded?: () => void;
|
||||
imageLightbox?: ImageLightboxItem | null;
|
||||
onRequestOpenImage?: () => number;
|
||||
onOpenImage?: (item: ImageLightboxItem, requestVersion?: number) => void;
|
||||
onCloseImage?: () => void;
|
||||
showNewMessages?: boolean;
|
||||
onScrollToBottom?: (options?: { smooth?: boolean }) => void;
|
||||
onRefresh: () => void;
|
||||
@@ -241,8 +220,6 @@ export type ChatProps = {
|
||||
onOpenSidebar?: (content: SidebarContent) => void;
|
||||
onOpenWorkspaceFile?: (target: { path: string; line?: number | null }) => void;
|
||||
onRevealWorkspaceFile?: (path: string) => void;
|
||||
onCloseSidebar?: () => void;
|
||||
onSplitRatioChange?: (ratio: number) => void;
|
||||
onChatScroll?: (event: Event) => void;
|
||||
basePath?: string;
|
||||
gatewayUrl?: string;
|
||||
@@ -287,9 +264,6 @@ function isImageLightboxEvent(event: Event): boolean {
|
||||
|
||||
export function renderChat(props: ChatProps) {
|
||||
const requestUpdate = props.onRequestUpdate ?? (() => {});
|
||||
const splitRatio = props.splitRatio ?? 0.6;
|
||||
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
|
||||
const sidebarStacked = props.sidebarStacked === true;
|
||||
const workspaceCollapsed = props.sessionWorkspace?.collapsed !== false;
|
||||
const workspaceDockBottom = Boolean(
|
||||
props.sessionWorkspace &&
|
||||
@@ -553,16 +527,11 @@ export function renderChat(props: ChatProps) {
|
||||
`
|
||||
: nothing}
|
||||
<div class="chat-workbench__main">
|
||||
<div
|
||||
class="chat-split-container ${sidebarOpen
|
||||
? "chat-split-container--open"
|
||||
: ""} ${sidebarOpen && sidebarStacked ? "chat-split-container--stacked" : ""}"
|
||||
>
|
||||
<div class="chat-split-container">
|
||||
<div
|
||||
class="chat-main ${props.sessionRailDocked && props.sessionRailMode === "expanded"
|
||||
? "chat-main--rail-docked"
|
||||
: ""}"
|
||||
style="flex: ${sidebarOpen ? `0 1 ${splitRatio * 100}%` : "1 1 100%"}"
|
||||
>
|
||||
<div class="chat-main__conversation">
|
||||
${thread}
|
||||
@@ -633,31 +602,9 @@ export function renderChat(props: ChatProps) {
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
${sidebarOpen
|
||||
? html`${renderChatResizableDivider({
|
||||
label: t("nav.resize"),
|
||||
orientation: sidebarStacked ? "horizontal" : "vertical",
|
||||
splitRatio,
|
||||
onResize: (event) => props.onSplitRatioChange?.(event.detail.splitRatio),
|
||||
})}
|
||||
<openclaw-chat-detail-panel
|
||||
class="chat-sidebar"
|
||||
.content=${props.sidebarContent ?? null}
|
||||
.loadFullMessage=${props.onLoadSidebarFullMessage ?? null}
|
||||
.canvasPluginSurfaceUrl=${props.canvasPluginSurfaceUrl ?? null}
|
||||
.embedSandboxMode=${props.embedSandboxMode ?? "scripts"}
|
||||
.allowExternalEmbedUrls=${props.allowExternalEmbedUrls ?? false}
|
||||
.onOpenWorkspaceFile=${props.onOpenWorkspaceFile ?? null}
|
||||
.onRevealInWorkspace=${props.onRevealWorkspaceFile ?? null}
|
||||
.onOpenImage=${props.onOpenImage ? openImmediateImage : null}
|
||||
@chat-detail-panel-close=${() => props.onCloseSidebar?.()}
|
||||
></openclaw-chat-detail-panel> `
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${renderChatImageLightbox(props.imageLightbox, props.onCloseImage)}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@ export function renderChatResizableDivider(props: {
|
||||
className?: string;
|
||||
label: string;
|
||||
maxRatio?: number;
|
||||
measureRatio?: () => number;
|
||||
measureSize?: () => number;
|
||||
minRatio?: number;
|
||||
onElement?: (element: Element | undefined) => void;
|
||||
onDragover?: (event: DragEvent) => void;
|
||||
onDrop?: (event: DragEvent) => void;
|
||||
onResize: (event: CustomEvent<{ splitRatio: number }>) => void;
|
||||
orientation: "horizontal" | "vertical";
|
||||
splitRatio: number;
|
||||
@@ -17,8 +21,12 @@ export function renderChatResizableDivider(props: {
|
||||
.splitRatio=${props.splitRatio}
|
||||
.minRatio=${props.minRatio ?? 0.4}
|
||||
.maxRatio=${props.maxRatio ?? 0.7}
|
||||
.measureRatio=${props.measureRatio}
|
||||
.measureSize=${props.measureSize}
|
||||
.label=${props.label}
|
||||
.orientation=${props.orientation}
|
||||
@dragover=${props.onDragover ?? (() => {})}
|
||||
@drop=${props.onDrop ?? (() => {})}
|
||||
@resize=${props.onResize}
|
||||
></resizable-divider>`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { SidebarSide, SidebarSlotId } from "../sidebar-layout.ts";
|
||||
|
||||
export type SidebarPanelTemplates = Partial<Record<SidebarSlotId, TemplateResult>>;
|
||||
|
||||
export type SidebarRegionCallbacks = {
|
||||
activatePanel: (panelId: string) => void;
|
||||
closeSlot: (slot: SidebarSlotId) => void;
|
||||
detachPanel: (panelId: string, side: SidebarSide, columnIndex: number) => void;
|
||||
mergePanel: (panelId: string, targetColumnId: string, panelIndex: number) => void;
|
||||
resizeColumn: (columnId: string, width: number) => void;
|
||||
};
|
||||
@@ -0,0 +1,395 @@
|
||||
import { html, nothing, render as renderTemplate } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { styleMap } from "lit/directives/style-map.js";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import "../../../components/web-awesome-tabs.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts";
|
||||
import {
|
||||
SIDEBAR_MIN_WIDTH_PX,
|
||||
isSidebarRegionCollapsed,
|
||||
type SidebarColumn,
|
||||
type SidebarLayout,
|
||||
type SidebarPanel,
|
||||
type SidebarSide,
|
||||
type SidebarSlotId,
|
||||
} from "../sidebar-layout.ts";
|
||||
import { resolveSplitDropZone } from "../split-drop-zone.ts";
|
||||
import { renderChatResizableDivider } from "./chat-resizable-divider.ts";
|
||||
import type { SidebarPanelTemplates, SidebarRegionCallbacks } from "./chat-sidebar-region-types.ts";
|
||||
import "./chat-sidebar.ts";
|
||||
import "./session-discussion-panel.ts";
|
||||
|
||||
function panelTitle(slot: SidebarSlotId): string {
|
||||
if (slot === "chat") {
|
||||
return t("chat.sidebarColumns.chat");
|
||||
}
|
||||
if (slot === "discussion") {
|
||||
return t("chat.sidebarColumns.discussion");
|
||||
}
|
||||
return t("chat.sidebarColumns.detail");
|
||||
}
|
||||
|
||||
function panelsOf(layout: SidebarLayout): SidebarPanel[] {
|
||||
return layout.columns.flatMap((column) => column.panels);
|
||||
}
|
||||
|
||||
class ChatSidebarRegion extends OpenClawLightDomElement {
|
||||
@property({ attribute: false }) layout: SidebarLayout = { columns: [] };
|
||||
@property({ attribute: false }) panelTemplates: SidebarPanelTemplates = {};
|
||||
@property({ attribute: false }) panelOpenUrls: Partial<Record<SidebarSlotId, string | null>> = {};
|
||||
@property({ attribute: false }) callbacks: SidebarRegionCallbacks | null = null;
|
||||
@property() sessionKey = "";
|
||||
@property() focusPanelId = "";
|
||||
@property({ type: Number }) focusVersion = 0;
|
||||
@property({ type: Boolean }) narrow = false;
|
||||
@property({ type: Number }) availableWidth = 0;
|
||||
|
||||
@state() private draggedPanelId = "";
|
||||
|
||||
private startDrag(event: DragEvent, panelId: string) {
|
||||
this.draggedPanelId = panelId;
|
||||
event.dataTransfer?.setData("application/x-openclaw-sidebar-panel", panelId);
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
}
|
||||
}
|
||||
|
||||
private endDrag() {
|
||||
this.draggedPanelId = "";
|
||||
}
|
||||
|
||||
private draggedPanel(): string {
|
||||
return this.draggedPanelId;
|
||||
}
|
||||
|
||||
private allowPanelDrop(event: DragEvent) {
|
||||
if (!this.draggedPanel()) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
}
|
||||
|
||||
private dropOnHeader(event: DragEvent, column: SidebarColumn) {
|
||||
const panelId = this.draggedPanel();
|
||||
if (!panelId) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const tab = event
|
||||
.composedPath()
|
||||
.find(
|
||||
(target): target is HTMLElement =>
|
||||
target instanceof HTMLElement && target.classList.contains("sidebar-column__tab"),
|
||||
);
|
||||
const targetPanelId = tab?.dataset.panelId;
|
||||
let panelIndex = column.panels.length;
|
||||
if (targetPanelId && tab) {
|
||||
const targetIndex = column.panels.findIndex((panel) => panel.id === targetPanelId);
|
||||
const rect = tab.getBoundingClientRect();
|
||||
const zone = resolveSplitDropZone(rect, event.clientX, event.clientY);
|
||||
panelIndex = targetIndex + (zone.kind === "edge" && zone.edge === "left" ? 0 : 1);
|
||||
}
|
||||
this.callbacks?.mergePanel(panelId, column.id, panelIndex);
|
||||
this.endDrag();
|
||||
}
|
||||
|
||||
private dropOnBoundary(
|
||||
event: DragEvent,
|
||||
side: SidebarSide,
|
||||
columnIndex: number,
|
||||
element: Element | undefined,
|
||||
) {
|
||||
const panelId = this.draggedPanel();
|
||||
if (!panelId || !(element instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
const zone = resolveSplitDropZone(rect, event.clientX, event.clientY);
|
||||
if (zone.kind !== "edge" || (zone.edge !== "left" && zone.edge !== "right")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.callbacks?.detachPanel(panelId, side, columnIndex);
|
||||
this.endDrag();
|
||||
}
|
||||
|
||||
private activate(panelId: string) {
|
||||
this.callbacks?.activatePanel(panelId);
|
||||
}
|
||||
|
||||
private renderHeader(column: SidebarColumn, activePanelId: string, narrow: boolean) {
|
||||
const active = column.panels.find((panel) => panel.id === activePanelId) ?? column.panels[0];
|
||||
if (!active) {
|
||||
return nothing;
|
||||
}
|
||||
const openUrl = this.panelOpenUrls[active.slot];
|
||||
return html`
|
||||
<div
|
||||
class="sidebar-column__header"
|
||||
@dragover=${(event: DragEvent) => (narrow ? undefined : this.allowPanelDrop(event))}
|
||||
@drop=${(event: DragEvent) => (narrow ? undefined : this.dropOnHeader(event, column))}
|
||||
>
|
||||
<wa-tab-group
|
||||
class="sidebar-column__tabs"
|
||||
.active=${active.id}
|
||||
activation="auto"
|
||||
without-scroll-controls
|
||||
@wa-tab-show=${(event: CustomEvent<{ name: string }>) => this.activate(event.detail.name)}
|
||||
>
|
||||
${column.panels.map(
|
||||
(panel) => html`
|
||||
<wa-tab
|
||||
class="sidebar-column__tab"
|
||||
panel=${panel.id}
|
||||
data-panel-id=${panel.id}
|
||||
.draggable=${!narrow}
|
||||
title=${t("chat.sidebarColumns.drag", { panel: panelTitle(panel.slot) })}
|
||||
@dragstart=${(event: DragEvent) =>
|
||||
narrow ? undefined : this.startDrag(event, panel.id)}
|
||||
@dragend=${() => this.endDrag()}
|
||||
>
|
||||
${panelTitle(panel.slot)}
|
||||
</wa-tab>
|
||||
`,
|
||||
)}
|
||||
</wa-tab-group>
|
||||
<div class="sidebar-column__actions">
|
||||
${openUrl
|
||||
? html`<a
|
||||
class="btn btn--ghost btn--icon"
|
||||
href=${openUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label=${t("chat.sessionDiscussion.openExternal")}
|
||||
title=${t("chat.sessionDiscussion.openExternal")}
|
||||
>${icons.externalLink}</a
|
||||
>`
|
||||
: nothing}
|
||||
<button
|
||||
class="btn btn--ghost btn--icon"
|
||||
type="button"
|
||||
aria-label=${t("chat.sidebarColumns.close", { panel: panelTitle(active.slot) })}
|
||||
title=${t("chat.sidebarColumns.close", { panel: panelTitle(active.slot) })}
|
||||
@click=${() => this.callbacks?.closeSlot(active.slot)}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderColumn(column: SidebarColumn) {
|
||||
const active =
|
||||
column.panels.find((panel) => panel.id === column.activePanelId) ?? column.panels[0];
|
||||
return html`
|
||||
<section
|
||||
class="sidebar-column"
|
||||
data-column-id=${column.id}
|
||||
style=${styleMap({ width: `${column.width}px` })}
|
||||
>
|
||||
${this.renderHeader(column, active?.id ?? "", false)}
|
||||
<div class="sidebar-column__body"></div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPanel(panel: SidebarPanel, collapsed: boolean, activePanelId: string) {
|
||||
const column = this.layout.columns.find((candidate) =>
|
||||
candidate.panels.some((entry) => entry.id === panel.id),
|
||||
);
|
||||
if (!column) {
|
||||
return nothing;
|
||||
}
|
||||
const sideColumns = this.layout.columns.filter((candidate) => candidate.side === column.side);
|
||||
const columnIndex = sideColumns.findIndex((candidate) => candidate.id === column.id);
|
||||
const offsetColumns =
|
||||
column.side === "left"
|
||||
? sideColumns.slice(0, columnIndex)
|
||||
: sideColumns.slice(columnIndex + 1);
|
||||
const offset = offsetColumns.reduce((sum, candidate) => sum + candidate.width + 4, 0);
|
||||
const panelStyle = collapsed
|
||||
? {}
|
||||
: { [column.side]: `${offset}px`, width: `${column.width}px` };
|
||||
return html`<div
|
||||
class="sidebar-column__panel ${collapsed
|
||||
? "sidebar-column__panel--narrow"
|
||||
: "sidebar-column__panel--wide"}"
|
||||
style=${styleMap(panelStyle)}
|
||||
?hidden=${panel.id !== (collapsed ? activePanelId : column.activePanelId)}
|
||||
>
|
||||
${this.panelTemplates[panel.slot]}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderDivider(column: SidebarColumn, side: SidebarSide, columnIndex: number) {
|
||||
let divider: Element | undefined;
|
||||
return renderChatResizableDivider({
|
||||
className: "sidebar-column__divider",
|
||||
label: t("chat.sidebarColumns.resize", {
|
||||
panel: panelTitle(column.panels[0]?.slot ?? "detail"),
|
||||
}),
|
||||
orientation: "vertical",
|
||||
splitRatio: 0.5,
|
||||
minRatio: 0.05,
|
||||
maxRatio: 0.95,
|
||||
measureRatio: () => {
|
||||
const { previous, next } = this.dividerNeighbors(column, side);
|
||||
const previousWidth = previous?.width ?? 0;
|
||||
const total = previousWidth + (next?.width ?? 0);
|
||||
return total > 0 ? previousWidth / total : 0.5;
|
||||
},
|
||||
measureSize: () => {
|
||||
const { previous, next } = this.dividerNeighbors(column, side);
|
||||
return (previous?.width ?? 0) + (next?.width ?? 0);
|
||||
},
|
||||
onElement: (element) => {
|
||||
divider = element;
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
const { previous, next } = this.dividerNeighbors(column, side);
|
||||
const total = (previous?.width ?? 0) + (next?.width ?? 0);
|
||||
if (total > 0) {
|
||||
(element as HTMLElement & { splitRatio: number }).splitRatio =
|
||||
(previous?.width ?? 0) / total;
|
||||
}
|
||||
});
|
||||
},
|
||||
onDragover: (event) => this.allowPanelDrop(event),
|
||||
onDrop: (event) => this.dropOnBoundary(event, side, columnIndex, divider),
|
||||
onResize: (event) => {
|
||||
const { previous, next } = this.dividerNeighbors(column, side);
|
||||
const total = (previous?.width ?? 0) + (next?.width ?? 0);
|
||||
if (total <= 0) {
|
||||
return;
|
||||
}
|
||||
const requested =
|
||||
side === "left" ? total * event.detail.splitRatio : total * (1 - event.detail.splitRatio);
|
||||
const regionWidth =
|
||||
this.availableWidth > 0
|
||||
? this.availableWidth
|
||||
: (this.parentElement?.getBoundingClientRect().width ?? 0);
|
||||
const maxWidth = Math.max(SIDEBAR_MIN_WIDTH_PX, regionWidth * 0.6);
|
||||
this.callbacks?.resizeColumn(column.id, Math.min(requested, maxWidth));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private dividerNeighbors(column: SidebarColumn, side: SidebarSide) {
|
||||
const sideColumns = this.layout.columns.filter((candidate) => candidate.side === side);
|
||||
const columnIndex = sideColumns.findIndex((candidate) => candidate.id === column.id);
|
||||
const columnElements = new Map(
|
||||
Array.from(
|
||||
this.parentElement?.querySelectorAll<HTMLElement>(".sidebar-column[data-column-id]") ?? [],
|
||||
(element) => [element.dataset.columnId, element],
|
||||
),
|
||||
);
|
||||
const primary = this.parentElement?.querySelector<HTMLElement>(".sidebar-region__primary");
|
||||
const previousElement =
|
||||
side === "left"
|
||||
? columnElements.get(column.id)
|
||||
: columnIndex > 0
|
||||
? columnElements.get(sideColumns[columnIndex - 1]?.id)
|
||||
: primary;
|
||||
const nextElement =
|
||||
side === "left"
|
||||
? columnIndex + 1 < sideColumns.length
|
||||
? columnElements.get(sideColumns[columnIndex + 1]?.id)
|
||||
: primary
|
||||
: columnElements.get(column.id);
|
||||
return {
|
||||
previous: previousElement?.getBoundingClientRect(),
|
||||
next: nextElement?.getBoundingClientRect(),
|
||||
};
|
||||
}
|
||||
|
||||
private renderNarrowColumn(panels: SidebarPanel[], activePanelId: string) {
|
||||
const collapsed: SidebarColumn = {
|
||||
id: "collapsed-sidebar-column",
|
||||
side: "right",
|
||||
panels,
|
||||
activePanelId,
|
||||
width: SIDEBAR_MIN_WIDTH_PX,
|
||||
};
|
||||
return panels.length > 0
|
||||
? html`<section class="sidebar-column sidebar-column--collapsed">
|
||||
${this.renderHeader(collapsed, activePanelId, true)}
|
||||
<div class="sidebar-column__body"></div>
|
||||
</section>`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
private renderState() {
|
||||
const width = this.availableWidth > 0 ? this.availableWidth : Number.POSITIVE_INFINITY;
|
||||
const collapsed = this.narrow || isSidebarRegionCollapsed(this.layout, width);
|
||||
const panels = panelsOf(this.layout);
|
||||
const activePanelId =
|
||||
panels.find((panel) => panel.id === this.focusPanelId)?.id ??
|
||||
this.layout.columns.at(-1)?.activePanelId ??
|
||||
panels[0]?.id ??
|
||||
"";
|
||||
return { activePanelId, collapsed, panels };
|
||||
}
|
||||
|
||||
private renderRight(collapsed: boolean, panels: SidebarPanel[], activePanelId: string) {
|
||||
if (collapsed) {
|
||||
return this.renderNarrowColumn(panels, activePanelId);
|
||||
}
|
||||
return this.layout.columns
|
||||
.filter((column) => column.side === "right")
|
||||
.map(
|
||||
(column, index) => html`
|
||||
${this.renderDivider(column, "right", index)} ${this.renderColumn(column)}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
private renderPanels(collapsed: boolean, panels: SidebarPanel[], activePanelId: string) {
|
||||
return repeat(
|
||||
panels,
|
||||
(panel) => panel.id,
|
||||
(panel) => this.renderPanel(panel, collapsed, activePanelId),
|
||||
);
|
||||
}
|
||||
|
||||
protected override updated() {
|
||||
const shell = this.parentElement;
|
||||
const rightRoot = shell?.querySelector<HTMLElement>(".sidebar-region__right-runtime");
|
||||
const panelsRoot = shell?.querySelector<HTMLElement>(".sidebar-region__panels-runtime");
|
||||
if (!rightRoot || !panelsRoot) {
|
||||
return;
|
||||
}
|
||||
const { activePanelId, collapsed, panels } = this.renderState();
|
||||
renderTemplate(this.renderRight(collapsed, panels, activePanelId), rightRoot);
|
||||
renderTemplate(this.renderPanels(collapsed, panels, activePanelId), panelsRoot);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { collapsed } = this.renderState();
|
||||
const left = this.layout.columns.filter((column) => column.side === "left");
|
||||
return html`${collapsed
|
||||
? nothing
|
||||
: left.map(
|
||||
(column, index) => html`
|
||||
${this.renderColumn(column)} ${this.renderDivider(column, "left", index + 1)}
|
||||
`,
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-chat-sidebar-region")) {
|
||||
customElements.define("openclaw-chat-sidebar-region", ChatSidebarRegion);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"openclaw-chat-sidebar-region": ChatSidebarRegion;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { html } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import "../../../components/resizable-divider.ts";
|
||||
import { mergePanelIntoColumn, openSlot } from "../sidebar-layout.ts";
|
||||
import "./chat-sidebar-region.runtime.ts";
|
||||
|
||||
type Region = HTMLElementTagNameMap["openclaw-chat-sidebar-region"] & {
|
||||
updateComplete: Promise<unknown>;
|
||||
};
|
||||
|
||||
const regions: Region[] = [];
|
||||
|
||||
async function createRegion(narrow: boolean) {
|
||||
const shell = document.createElement("div");
|
||||
shell.className = `sidebar-region ${narrow ? "sidebar-region--narrow" : ""}`;
|
||||
const region = document.createElement("openclaw-chat-sidebar-region") as Region;
|
||||
region.layout = openSlot(openSlot(openSlot({ columns: [] }, "discussion"), "chat"), "detail");
|
||||
region.panelTemplates = {
|
||||
chat: html`<div data-panel="chat">Chat panel</div>`,
|
||||
detail: html`<div data-panel="detail">Detail panel</div>`,
|
||||
discussion: html`<div data-panel="discussion">Discussion panel</div>`,
|
||||
};
|
||||
region.callbacks = {
|
||||
activatePanel: vi.fn(),
|
||||
closeSlot: vi.fn(),
|
||||
detachPanel: vi.fn(),
|
||||
mergePanel: vi.fn(),
|
||||
resizeColumn: vi.fn(),
|
||||
};
|
||||
region.narrow = narrow;
|
||||
region.availableWidth = narrow ? 620 : 1_600;
|
||||
const primary = document.createElement("div");
|
||||
primary.className = "sidebar-region__primary";
|
||||
primary.innerHTML = "<main data-primary>Primary</main>";
|
||||
const rightRuntime = document.createElement("div");
|
||||
rightRuntime.className = "sidebar-region__right-runtime";
|
||||
const panelsRuntime = document.createElement("div");
|
||||
panelsRuntime.className = "sidebar-region__panels-runtime";
|
||||
shell.append(region, primary, rightRuntime, panelsRuntime);
|
||||
document.body.append(shell);
|
||||
regions.push(region);
|
||||
await region.updateComplete;
|
||||
return region;
|
||||
}
|
||||
|
||||
function regionRoot(region: Region): HTMLElement {
|
||||
return region.parentElement!;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const region of regions.splice(0)) {
|
||||
region.parentElement?.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("chat sidebar region", () => {
|
||||
it("renders independent columns in rank order on wide panes", async () => {
|
||||
const region = await createRegion(false);
|
||||
expect(regionRoot(region).querySelectorAll(".sidebar-column")).toHaveLength(3);
|
||||
expect(
|
||||
Array.from(regionRoot(region).querySelectorAll(".sidebar-column__tab"), (tab) =>
|
||||
tab.textContent?.trim(),
|
||||
),
|
||||
).toEqual(["Chat", "Details", "Discussion"]);
|
||||
expect(regionRoot(region).querySelector("[data-primary]")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("collapses every open panel into one tabbed column on narrow panes", async () => {
|
||||
const region = await createRegion(true);
|
||||
expect(regionRoot(region).querySelectorAll(".sidebar-column")).toHaveLength(1);
|
||||
expect(regionRoot(region).querySelectorAll(".sidebar-column__tab")).toHaveLength(3);
|
||||
expect(
|
||||
Array.from(
|
||||
regionRoot(region).querySelectorAll<HTMLButtonElement>(".sidebar-column__tab"),
|
||||
).every((tab) => !tab.draggable),
|
||||
).toBe(true);
|
||||
|
||||
regionRoot(region).querySelectorAll<HTMLButtonElement>(".sidebar-column__tab")[1]?.click();
|
||||
await region.updateComplete;
|
||||
|
||||
expect(regionRoot(region).querySelector('[data-panel="detail"]')).not.toBeNull();
|
||||
expect(regionRoot(region).querySelector('[data-panel="chat"]')).not.toBeNull();
|
||||
expect(region.callbacks?.activatePanel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("activates a panel opened after the narrow region is already visible", async () => {
|
||||
const region = await createRegion(true);
|
||||
region.layout = openSlot({ columns: [] }, "chat");
|
||||
await region.updateComplete;
|
||||
|
||||
region.layout = openSlot(region.layout, "discussion");
|
||||
await region.updateComplete;
|
||||
|
||||
expect(regionRoot(region).querySelector('[data-panel="discussion"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("foregrounds an already-mounted panel from a focus request", async () => {
|
||||
const region = await createRegion(true);
|
||||
const discussion = region.layout.columns[2]!.panels[0]!;
|
||||
|
||||
region.focusPanelId = discussion.id;
|
||||
region.focusVersion += 1;
|
||||
await region.updateComplete;
|
||||
|
||||
expect(
|
||||
regionRoot(region).querySelector('[data-panel="discussion"]')?.parentElement?.hidden,
|
||||
).toBe(false);
|
||||
expect(regionRoot(region).querySelector('[data-panel="chat"]')?.parentElement?.hidden).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("routes native header drops through the merge callback", async () => {
|
||||
const region = await createRegion(false);
|
||||
const tabs = regionRoot(region).querySelectorAll<HTMLElement>(".sidebar-column__tab");
|
||||
const source = tabs[0];
|
||||
const target = tabs[1];
|
||||
expect(source).toBeDefined();
|
||||
expect(target).toBeDefined();
|
||||
const values = new Map<string, string>();
|
||||
const dataTransfer = {
|
||||
effectAllowed: "none",
|
||||
dropEffect: "none",
|
||||
getData: (type: string) => values.get(type) ?? "",
|
||||
setData: (type: string, value: string) => values.set(type, value),
|
||||
};
|
||||
const start = new Event("dragstart", { bubbles: true }) as DragEvent;
|
||||
Object.defineProperty(start, "dataTransfer", { value: dataTransfer });
|
||||
source!.dispatchEvent(start);
|
||||
const drop = new Event("drop", { bubbles: true }) as DragEvent;
|
||||
Object.defineProperties(drop, {
|
||||
clientX: { value: 1 },
|
||||
clientY: { value: 1 },
|
||||
dataTransfer: { value: dataTransfer },
|
||||
});
|
||||
target!.dispatchEvent(drop);
|
||||
|
||||
expect(region.callbacks?.mergePanel).toHaveBeenCalledWith(
|
||||
region.layout.columns[0]?.panels[0]?.id,
|
||||
region.layout.columns[1]?.id,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("routes boundary drops through the detach callback", async () => {
|
||||
const region = await createRegion(false);
|
||||
const source = regionRoot(region).querySelectorAll<HTMLElement>(".sidebar-column__tab")[1]!;
|
||||
const boundary = regionRoot(region).querySelector<HTMLElement>("resizable-divider")!;
|
||||
boundary.getBoundingClientRect = () => ({ left: 0, top: 0, width: 4, height: 100 }) as DOMRect;
|
||||
const values = new Map<string, string>();
|
||||
const dataTransfer = {
|
||||
effectAllowed: "none",
|
||||
dropEffect: "none",
|
||||
getData: (type: string) => values.get(type) ?? "",
|
||||
setData: (type: string, value: string) => values.set(type, value),
|
||||
};
|
||||
const start = new Event("dragstart", { bubbles: true }) as DragEvent;
|
||||
Object.defineProperty(start, "dataTransfer", { value: dataTransfer });
|
||||
source.dispatchEvent(start);
|
||||
const drop = new Event("drop", { bubbles: true }) as DragEvent;
|
||||
Object.defineProperties(drop, {
|
||||
clientX: { value: 1 },
|
||||
clientY: { value: 50 },
|
||||
dataTransfer: { value: dataTransfer },
|
||||
});
|
||||
boundary.dispatchEvent(drop);
|
||||
|
||||
expect(region.callbacks?.detachPanel).toHaveBeenCalledWith(
|
||||
region.layout.columns[1]?.panels[0]?.id,
|
||||
"right",
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("resizes the primary-adjacent column across the light-DOM shell boundary", async () => {
|
||||
const region = await createRegion(false);
|
||||
const primary = regionRoot(region).querySelector<HTMLElement>(".sidebar-region__primary");
|
||||
const divider = regionRoot(region).querySelector<HTMLElement>(
|
||||
".sidebar-region__right-runtime .sidebar-column__divider",
|
||||
);
|
||||
const column = region.layout.columns.find((candidate) => candidate.side === "right");
|
||||
expect(primary).toBeDefined();
|
||||
expect(divider).toBeDefined();
|
||||
expect(column).toBeDefined();
|
||||
primary!.getBoundingClientRect = () => ({ width: 800 }) as DOMRect;
|
||||
regionRoot(region).querySelector<HTMLElement>(
|
||||
`[data-column-id="${column!.id}"]`,
|
||||
)!.getBoundingClientRect = () => ({ width: 320 }) as DOMRect;
|
||||
divider!.setPointerCapture = vi.fn();
|
||||
divider!.releasePointerCapture = vi.fn();
|
||||
divider!.hasPointerCapture = vi.fn(() => true);
|
||||
const pointerDown = new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
clientX: 500,
|
||||
});
|
||||
Object.defineProperty(pointerDown, "pointerId", { value: 7 });
|
||||
divider!.dispatchEvent(pointerDown);
|
||||
document.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX: 612 }));
|
||||
document.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 612 }));
|
||||
|
||||
const resizedWidth = vi.mocked(region.callbacks!.resizeColumn).mock.lastCall?.[1];
|
||||
expect(region.callbacks?.resizeColumn).toHaveBeenCalledWith(column!.id, expect.any(Number));
|
||||
expect(resizedWidth).toBeCloseTo(208);
|
||||
|
||||
vi.mocked(region.callbacks!.resizeColumn).mockClear();
|
||||
divider!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowRight" }));
|
||||
const keyboardWidth = vi.mocked(region.callbacks!.resizeColumn).mock.lastCall?.[1];
|
||||
expect(keyboardWidth).toBeCloseTo(297.6);
|
||||
});
|
||||
|
||||
it("ignores a drag payload started by another sidebar region", async () => {
|
||||
const sourceRegion = await createRegion(false);
|
||||
const targetRegion = await createRegion(false);
|
||||
const source = regionRoot(sourceRegion).querySelector<HTMLElement>(".sidebar-column__tab")!;
|
||||
const target = regionRoot(targetRegion).querySelector<HTMLElement>(".sidebar-column__tab")!;
|
||||
const values = new Map<string, string>();
|
||||
const dataTransfer = {
|
||||
effectAllowed: "none",
|
||||
dropEffect: "none",
|
||||
getData: (type: string) => values.get(type) ?? "",
|
||||
setData: (type: string, value: string) => values.set(type, value),
|
||||
};
|
||||
const start = new Event("dragstart", { bubbles: true }) as DragEvent;
|
||||
Object.defineProperty(start, "dataTransfer", { value: dataTransfer });
|
||||
source.dispatchEvent(start);
|
||||
const drop = new Event("drop", { bubbles: true }) as DragEvent;
|
||||
Object.defineProperties(drop, {
|
||||
clientX: { value: 1 },
|
||||
clientY: { value: 1 },
|
||||
dataTransfer: { value: dataTransfer },
|
||||
});
|
||||
target.dispatchEvent(drop);
|
||||
|
||||
expect(targetRegion.callbacks?.mergePanel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves a panel DOM node when it moves between columns", async () => {
|
||||
const region = await createRegion(false);
|
||||
const detail = region.layout.columns[1]!.panels[0]!;
|
||||
const target = region.layout.columns[2]!;
|
||||
const detailNode = regionRoot(region).querySelector('[data-panel="detail"]');
|
||||
|
||||
region.layout = mergePanelIntoColumn(region.layout, detail.id, target.id, 0);
|
||||
await region.updateComplete;
|
||||
|
||||
expect(regionRoot(region).querySelector('[data-panel="detail"]')).toBe(detailNode);
|
||||
});
|
||||
|
||||
it("preserves a panel DOM node while crossing the responsive breakpoint", async () => {
|
||||
const region = await createRegion(false);
|
||||
const detailNode = regionRoot(region).querySelector('[data-panel="detail"]');
|
||||
|
||||
region.narrow = true;
|
||||
region.availableWidth = 620;
|
||||
region.parentElement?.classList.add("sidebar-region--narrow");
|
||||
await region.updateComplete;
|
||||
|
||||
expect(regionRoot(region).querySelector('[data-panel="detail"]')).toBe(detailNode);
|
||||
});
|
||||
|
||||
it("preserves the primary DOM node while crossing the responsive breakpoint", async () => {
|
||||
const region = await createRegion(false);
|
||||
const primaryNode = regionRoot(region).querySelector("[data-primary]");
|
||||
|
||||
region.narrow = true;
|
||||
region.availableWidth = 620;
|
||||
await region.updateComplete;
|
||||
|
||||
expect(regionRoot(region).querySelector("[data-primary]")).toBe(primaryNode);
|
||||
});
|
||||
|
||||
it("restores the persisted active tab when the session changes", async () => {
|
||||
const region = await createRegion(true);
|
||||
const chat = region.layout.columns[0]!.panels[0]!;
|
||||
const discussion = region.layout.columns[2]!.panels[0]!;
|
||||
let merged = mergePanelIntoColumn(
|
||||
region.layout,
|
||||
discussion.id,
|
||||
region.layout.columns[0]!.id,
|
||||
1,
|
||||
);
|
||||
const detail = merged.columns[1]!.panels[0]!;
|
||||
merged = mergePanelIntoColumn(merged, detail.id, merged.columns[0]!.id, 1);
|
||||
merged.columns[0]!.activePanelId = chat.id;
|
||||
region.layout = merged;
|
||||
region.sessionKey = "session-a";
|
||||
await region.updateComplete;
|
||||
merged = { ...merged, columns: merged.columns.map((column) => ({ ...column })) };
|
||||
merged.columns[0]!.activePanelId = discussion.id;
|
||||
|
||||
region.layout = merged;
|
||||
region.sessionKey = "session-b";
|
||||
await region.updateComplete;
|
||||
|
||||
expect(
|
||||
regionRoot(region).querySelector('[data-panel="discussion"]')?.parentElement?.hidden,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("gives a simultaneous explicit focus request precedence on session change", async () => {
|
||||
const region = await createRegion(true);
|
||||
const detail = region.layout.columns[1]!.panels[0]!;
|
||||
|
||||
region.sessionKey = "session-b";
|
||||
region.focusPanelId = detail.id;
|
||||
region.focusVersion += 1;
|
||||
await region.updateComplete;
|
||||
|
||||
expect(regionRoot(region).querySelector('[data-panel="detail"]')?.parentElement?.hidden).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the narrow grid off when no panel is open", async () => {
|
||||
const region = await createRegion(true);
|
||||
region.layout = { columns: [] };
|
||||
region.parentElement?.classList.remove("sidebar-region--narrow");
|
||||
await region.updateComplete;
|
||||
|
||||
// The two-row narrow grid must not reserve a panel row for an empty layout,
|
||||
// or every default mobile chat pane loses half its height.
|
||||
expect(region.parentElement?.classList.contains("sidebar-region--narrow")).toBe(false);
|
||||
expect(regionRoot(region).querySelector("[data-primary]")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -20,18 +20,10 @@ import { copyToClipboard } from "../../../lib/clipboard.ts";
|
||||
import { type EditorId, openEditor } from "../../../lib/editor-links.ts";
|
||||
import { openExternalUrlSafe } from "../../../lib/open-external-url.ts";
|
||||
import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts";
|
||||
import "./session-discussion-panel.ts";
|
||||
import "./session-diff-panel.ts";
|
||||
import { renderChatSidebarEditorMenu } from "./chat-sidebar-editor-menu.ts";
|
||||
import type { FileEditorViewHandle } from "./file-editor-view.ts";
|
||||
import type { SessionDiffLoader } from "./session-diff-panel.ts";
|
||||
import type {
|
||||
SessionDiscussionInfoLoader,
|
||||
SessionDiscussionOpener,
|
||||
SessionDiscussionStateListener,
|
||||
} from "./session-discussion-panel.ts";
|
||||
|
||||
export const CHAT_DETAIL_FULL_MESSAGE_MAX_CHARS = 500_000;
|
||||
|
||||
type DetailUnavailableReason = "not_found" | "oversized" | "not_visible";
|
||||
export type DetailFullMessageResult = {
|
||||
@@ -87,19 +79,6 @@ type SessionDiffSidebarContent = {
|
||||
unavailableReason?: DetailUnavailableReason | null;
|
||||
};
|
||||
|
||||
type SessionDiscussionSidebarContent = {
|
||||
kind: "session-discussion";
|
||||
sessionKey: string;
|
||||
canOpen: boolean;
|
||||
openUrl?: string | null;
|
||||
loadInfo: SessionDiscussionInfoLoader;
|
||||
openDiscussion: SessionDiscussionOpener;
|
||||
onStateChange: SessionDiscussionStateListener;
|
||||
rawText?: string | null;
|
||||
fullMessageRequest?: SidebarFullMessageRequest;
|
||||
unavailableReason?: DetailUnavailableReason | null;
|
||||
};
|
||||
|
||||
type FileSaveOutcome =
|
||||
| { ok: true; hash: string; updatedAtMs?: number }
|
||||
| { ok: false; code: "conflict"; currentHash?: string }
|
||||
@@ -153,7 +132,6 @@ export type SidebarContent =
|
||||
| CanvasSidebarContent
|
||||
| ImageSidebarContent
|
||||
| FileSidebarContent
|
||||
| SessionDiscussionSidebarContent
|
||||
| SessionDiffSidebarContent;
|
||||
|
||||
function hasFullMessageRequest(content: SidebarContent): content is SidebarContent & {
|
||||
@@ -517,6 +495,7 @@ type MarkdownSidebarProps = {
|
||||
canvasPluginSurfaceUrl?: string | null;
|
||||
embedSandboxMode?: EmbedSandboxMode;
|
||||
allowExternalEmbedUrls?: boolean;
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
function renderMarkdownSidebar(props: MarkdownSidebarProps) {
|
||||
@@ -540,8 +519,6 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) {
|
||||
props.allowExternalEmbedUrls ?? false,
|
||||
)
|
||||
: null;
|
||||
const discussionOpenUrl =
|
||||
content?.kind === "session-discussion" ? (content.openUrl ?? null) : null;
|
||||
const title =
|
||||
content?.kind === "canvas"
|
||||
? content.title?.trim() || "Render Preview"
|
||||
@@ -551,48 +528,29 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) {
|
||||
? content.name.trim() || "File"
|
||||
: content?.kind === "session-diff"
|
||||
? t("chat.sessionDiff.title")
|
||||
: content?.kind === "session-discussion"
|
||||
? t("chat.sessionDiscussion.title")
|
||||
: content?.kind === "markdown"
|
||||
? "Markdown Preview"
|
||||
: "Tool Details";
|
||||
: content?.kind === "markdown"
|
||||
? "Markdown Preview"
|
||||
: "Tool Details";
|
||||
return html`
|
||||
<div class="sidebar-panel">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-title">${title}</div>
|
||||
<div class="sidebar-header__actions">
|
||||
${discussionOpenUrl
|
||||
? html`
|
||||
<openclaw-tooltip .content=${t("chat.sessionDiscussion.openExternal")}>
|
||||
<a
|
||||
class="btn btn--ghost btn--icon"
|
||||
href=${discussionOpenUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label=${t("chat.sessionDiscussion.openExternal")}
|
||||
>
|
||||
${icons.externalLink}
|
||||
</a>
|
||||
</openclaw-tooltip>
|
||||
`
|
||||
: nothing}
|
||||
<openclaw-tooltip .content=${t("chat.detailPanel.close")}>
|
||||
<button
|
||||
@click=${props.onClose}
|
||||
class="btn"
|
||||
type="button"
|
||||
aria-label=${t("chat.detailPanel.close")}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="sidebar-content ${content?.kind === "session-discussion"
|
||||
? "sidebar-content--discussion"
|
||||
: ""}"
|
||||
>
|
||||
${props.embedded
|
||||
? nothing
|
||||
: html`<div class="sidebar-header">
|
||||
<div class="sidebar-title">${title}</div>
|
||||
<div class="sidebar-header__actions">
|
||||
<openclaw-tooltip .content=${t("chat.detailPanel.close")}>
|
||||
<button
|
||||
@click=${props.onClose}
|
||||
class="btn"
|
||||
type="button"
|
||||
aria-label=${t("chat.detailPanel.close")}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
</div>
|
||||
</div> `}
|
||||
<div class="sidebar-content">
|
||||
${props.error
|
||||
? html`
|
||||
<div class="callout danger">${props.error}</div>
|
||||
@@ -614,34 +572,54 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) {
|
||||
? renderFileSidebarContent(content, props.onViewRawText, props.fileView)
|
||||
: content.kind === "session-diff"
|
||||
? html`<openclaw-session-diff .loader=${content.load}></openclaw-session-diff>`
|
||||
: content.kind === "session-discussion"
|
||||
: content.kind === "canvas"
|
||||
? html`
|
||||
<openclaw-session-discussion
|
||||
.sessionKey=${content.sessionKey}
|
||||
.canOpen=${content.canOpen}
|
||||
.loadInfo=${content.loadInfo}
|
||||
.openDiscussion=${content.openDiscussion}
|
||||
.onStateChange=${content.onStateChange}
|
||||
></openclaw-session-discussion>
|
||||
<div class="chat-tool-card__preview" data-kind="canvas">
|
||||
<div class="chat-tool-card__preview-panel" data-side="front">
|
||||
${keyed(
|
||||
`${canvasSandbox}\u0000${canvasSrc ?? ""}\u0000${content.preferredHeight ?? ""}`,
|
||||
html`
|
||||
<iframe
|
||||
class="chat-tool-card__preview-frame"
|
||||
title=${content.title?.trim() || "Render preview"}
|
||||
sandbox=${canvasSandbox}
|
||||
src=${canvasSrc ?? nothing}
|
||||
style=${content.preferredHeight
|
||||
? `height:${content.preferredHeight}px`
|
||||
: ""}
|
||||
></iframe>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
${content.rawText?.trim()
|
||||
? html`
|
||||
<div style="margin-top: 12px;">
|
||||
<button @click=${props.onViewRawText} class="btn" type="button">
|
||||
${t("chat.detailPanel.viewRawText")}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: content.kind === "canvas"
|
||||
: content.kind === "image"
|
||||
? html`
|
||||
<div class="chat-tool-card__preview" data-kind="canvas">
|
||||
<div class="chat-tool-card__preview" data-kind="image">
|
||||
<div class="chat-tool-card__preview-panel" data-side="front">
|
||||
${keyed(
|
||||
`${canvasSandbox}\u0000${canvasSrc ?? ""}\u0000${content.preferredHeight ?? ""}`,
|
||||
html`
|
||||
<iframe
|
||||
class="chat-tool-card__preview-frame"
|
||||
title=${content.title?.trim() || "Render preview"}
|
||||
sandbox=${canvasSandbox}
|
||||
src=${canvasSrc ?? nothing}
|
||||
style=${content.preferredHeight
|
||||
? `height:${content.preferredHeight}px`
|
||||
: ""}
|
||||
></iframe>
|
||||
`,
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
class="chat-tool-card__preview-image-button"
|
||||
aria-label=${t("chat.imageLightbox.open", { title })}
|
||||
@click=${() =>
|
||||
openSidebarImage(props.onOpenImage, content.src, title)}
|
||||
>
|
||||
<img
|
||||
class="chat-tool-card__preview-image"
|
||||
src=${content.src}
|
||||
alt=${title}
|
||||
style="display:block;max-width:100%;height:auto;border-radius:8px;"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
${content.rawText?.trim()
|
||||
? html`
|
||||
@@ -654,69 +632,35 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) {
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: content.kind === "image"
|
||||
? html`
|
||||
<div class="chat-tool-card__preview" data-kind="image">
|
||||
<div class="chat-tool-card__preview-panel" data-side="front">
|
||||
<button
|
||||
type="button"
|
||||
class="chat-tool-card__preview-image-button"
|
||||
aria-label=${t("chat.imageLightbox.open", { title })}
|
||||
@click=${() =>
|
||||
openSidebarImage(props.onOpenImage, content.src, title)}
|
||||
>
|
||||
<img
|
||||
class="chat-tool-card__preview-image"
|
||||
src=${content.src}
|
||||
alt=${title}
|
||||
style="display:block;max-width:100%;height:auto;border-radius:8px;"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
${content.rawText?.trim()
|
||||
? html`
|
||||
<div style="margin-top: 12px;">
|
||||
<button @click=${props.onViewRawText} class="btn" type="button">
|
||||
${t("chat.detailPanel.viewRawText")}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<section class="sidebar-markdown-shell">
|
||||
<div class="sidebar-markdown-shell__toolbar">
|
||||
<div class="sidebar-markdown-shell__intro">
|
||||
<div class="sidebar-markdown-shell__eyebrow">
|
||||
${icons.scrollText}
|
||||
<span>${t("chat.detailPanel.renderedMarkdown")}</span>
|
||||
</div>
|
||||
<div class="sidebar-markdown-shell__hint">
|
||||
${t("chat.detailPanel.renderedMarkdownHint")}
|
||||
</div>
|
||||
: html`
|
||||
<section class="sidebar-markdown-shell">
|
||||
<div class="sidebar-markdown-shell__toolbar">
|
||||
<div class="sidebar-markdown-shell__intro">
|
||||
<div class="sidebar-markdown-shell__eyebrow">
|
||||
${icons.scrollText}
|
||||
<span>${t("chat.detailPanel.renderedMarkdown")}</span>
|
||||
</div>
|
||||
<div class="sidebar-markdown-shell__hint">
|
||||
${t("chat.detailPanel.renderedMarkdownHint")}
|
||||
</div>
|
||||
<button
|
||||
@click=${props.onViewRawText}
|
||||
class="btn btn--sm"
|
||||
type="button"
|
||||
>
|
||||
${t("chat.detailPanel.viewRawText")}
|
||||
</button>
|
||||
</div>
|
||||
${markdownHtml
|
||||
? html`
|
||||
<article class="sidebar-markdown-reader sidebar-markdown">
|
||||
${unsafeHTML(markdownHtml)}
|
||||
</article>
|
||||
`
|
||||
: html`
|
||||
<div class="sidebar-markdown-empty">
|
||||
${t("chat.detailPanel.noPreviewableMarkdown")}
|
||||
</div>
|
||||
`}
|
||||
</section>
|
||||
`
|
||||
<button @click=${props.onViewRawText} class="btn btn--sm" type="button">
|
||||
${t("chat.detailPanel.viewRawText")}
|
||||
</button>
|
||||
</div>
|
||||
${markdownHtml
|
||||
? html`
|
||||
<article class="sidebar-markdown-reader sidebar-markdown">
|
||||
${unsafeHTML(markdownHtml)}
|
||||
</article>
|
||||
`
|
||||
: html`
|
||||
<div class="sidebar-markdown-empty">
|
||||
${t("chat.detailPanel.noPreviewableMarkdown")}
|
||||
</div>
|
||||
`}
|
||||
</section>
|
||||
`
|
||||
: html` <div class="muted">${t("chat.detailPanel.noContent")}</div> `}
|
||||
</div>
|
||||
</div>
|
||||
@@ -731,6 +675,7 @@ class ChatDetailPanel extends OpenClawLightDomElement {
|
||||
@property() canvasPluginSurfaceUrl: string | null = null;
|
||||
@property() embedSandboxMode: EmbedSandboxMode = "scripts";
|
||||
@property({ type: Boolean }) allowExternalEmbedUrls = false;
|
||||
@property({ type: Boolean }) embedded = false;
|
||||
@property({ attribute: false }) onOpenWorkspaceFile?:
|
||||
| ((target: { path: string; line?: number | null }) => void)
|
||||
| null = null;
|
||||
@@ -1335,11 +1280,8 @@ class ChatDetailPanel extends OpenClawLightDomElement {
|
||||
const currentMatchIndex = matches.length
|
||||
? Math.min(this.fileSearchMatchIndex, matches.length - 1)
|
||||
: 0;
|
||||
// The discussion iframe has no intrinsic height, so its host wrapper must
|
||||
// stretch; content-sized kinds (files, tool details) keep auto height.
|
||||
const fillHost = this.visibleContent?.kind === "session-discussion";
|
||||
return html`
|
||||
<div class=${fillHost ? "sidebar-panel-host--fill" : ""} @click=${this.handlePanelClick}>
|
||||
<div @click=${this.handlePanelClick}>
|
||||
${renderMarkdownSidebar({
|
||||
content: this.visibleContent,
|
||||
error: this.error,
|
||||
@@ -1376,6 +1318,7 @@ class ChatDetailPanel extends OpenClawLightDomElement {
|
||||
canvasPluginSurfaceUrl: this.canvasPluginSurfaceUrl,
|
||||
embedSandboxMode: this.embedSandboxMode,
|
||||
allowExternalEmbedUrls: this.allowExternalEmbedUrls,
|
||||
embedded: this.embedded,
|
||||
onClose: this.close,
|
||||
onOpenImage: this.onOpenImage ?? undefined,
|
||||
onViewRawText: this.showRawText,
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
SessionDiscussionInfoLoader,
|
||||
SessionDiscussionOpener,
|
||||
SessionDiscussionStateListener,
|
||||
} from "./session-discussion-panel.ts";
|
||||
import type { SessionDiscussionPanelConfig } from "./session-discussion-panel.ts";
|
||||
import "./session-discussion-panel.ts";
|
||||
|
||||
type SessionDiscussionInfoLoader = SessionDiscussionPanelConfig["loadInfo"];
|
||||
type SessionDiscussionOpener = SessionDiscussionPanelConfig["openDiscussion"];
|
||||
type SessionDiscussionStateListener = SessionDiscussionPanelConfig["onStateChange"];
|
||||
|
||||
type DiscussionPanelElement = HTMLElement & {
|
||||
sessionKey: string;
|
||||
canOpen: boolean;
|
||||
sourceGeneration: number;
|
||||
loadInfo: SessionDiscussionInfoLoader;
|
||||
openDiscussion: SessionDiscussionOpener;
|
||||
onStateChange: SessionDiscussionStateListener;
|
||||
@@ -165,6 +166,34 @@ describe("session discussion panel", () => {
|
||||
expect(panel.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("replaces source-owned content when the gateway generation changes", async () => {
|
||||
const loadInfo = vi
|
||||
.fn<SessionDiscussionInfoLoader>()
|
||||
.mockResolvedValueOnce({
|
||||
state: "open",
|
||||
embedUrl: "https://old.example/embed/thread",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
state: "open",
|
||||
embedUrl: "https://new.example/embed/thread",
|
||||
});
|
||||
const panel = mount({ loadInfo, openDiscussion: vi.fn() });
|
||||
await vi.waitFor(() => {
|
||||
expect(panel.querySelector("iframe")?.getAttribute("src")).toBe(
|
||||
"https://old.example/embed/thread",
|
||||
);
|
||||
});
|
||||
|
||||
panel.sourceGeneration += 1;
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(panel.querySelector("iframe")?.getAttribute("src")).toBe(
|
||||
"https://new.example/embed/thread",
|
||||
);
|
||||
});
|
||||
expect(loadInfo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("ignores an in-flight open result after the session changes", async () => {
|
||||
let resolveFirstOpen: ((value: { state: "open"; embedUrl: string }) => void) | undefined;
|
||||
const loadInfo = vi
|
||||
|
||||
@@ -7,14 +7,23 @@ import type {
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts";
|
||||
|
||||
export type SessionDiscussionInfoLoader = (sessionKey: string) => Promise<SessionDiscussionInfo>;
|
||||
export type SessionDiscussionOpener = (sessionKey: string) => Promise<SessionDiscussionInfo>;
|
||||
export type SessionDiscussionStateListener = (
|
||||
type SessionDiscussionInfoLoader = (sessionKey: string) => Promise<SessionDiscussionInfo>;
|
||||
type SessionDiscussionOpener = (sessionKey: string) => Promise<SessionDiscussionInfo>;
|
||||
type SessionDiscussionStateListener = (
|
||||
sessionKey: string,
|
||||
discussionState: SessionDiscussionState,
|
||||
openUrl: string | null,
|
||||
) => void;
|
||||
|
||||
export type SessionDiscussionPanelConfig = {
|
||||
sessionKey: string;
|
||||
canOpen: boolean;
|
||||
openUrl: string | null;
|
||||
loadInfo: SessionDiscussionInfoLoader;
|
||||
openDiscussion: SessionDiscussionOpener;
|
||||
onStateChange: SessionDiscussionStateListener;
|
||||
};
|
||||
|
||||
function resolveDiscussionUrl(value: string | undefined): string | null {
|
||||
if (!value?.trim()) {
|
||||
return null;
|
||||
@@ -45,6 +54,7 @@ class SessionDiscussionPanel extends OpenClawLightDomElement {
|
||||
@property({ attribute: false }) openDiscussion: SessionDiscussionOpener | null = null;
|
||||
@property({ attribute: false }) onStateChange: SessionDiscussionStateListener | null = null;
|
||||
@property({ type: Boolean }) canOpen = true;
|
||||
@property({ type: Number }) sourceGeneration = 0;
|
||||
|
||||
@state() private info: SessionDiscussionInfo | null = null;
|
||||
@state() private loading = false;
|
||||
@@ -58,7 +68,7 @@ class SessionDiscussionPanel extends OpenClawLightDomElement {
|
||||
}
|
||||
|
||||
protected override updated(changed: Map<string, unknown>) {
|
||||
if (changed.has("sessionKey") || changed.has("loadInfo")) {
|
||||
if (changed.has("sessionKey") || changed.has("loadInfo") || changed.has("sourceGeneration")) {
|
||||
void this.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { isRecord } from "@openclaw/normalization-core";
|
||||
import type {
|
||||
SidebarColumn,
|
||||
SidebarLayout,
|
||||
SidebarPanel,
|
||||
SidebarSlotId,
|
||||
} from "./sidebar-layout-types.ts";
|
||||
|
||||
const DEFAULT_WIDTH = 360;
|
||||
const CHAT_DEFAULT_WIDTH = 480;
|
||||
const MIN_WIDTH = 260;
|
||||
const MAX_WIDTH = 1_200;
|
||||
|
||||
function isSlotId(value: unknown): value is SidebarSlotId {
|
||||
return value === "chat" || value === "discussion" || value === "detail";
|
||||
}
|
||||
|
||||
function clampWidth(width: number): number {
|
||||
return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, width));
|
||||
}
|
||||
|
||||
function uniqueId(value: unknown, fallback: string, used: Set<string>): string {
|
||||
const base = typeof value === "string" && value.trim() ? value.trim() : fallback;
|
||||
let id = base;
|
||||
let suffix = 2;
|
||||
while (used.has(id)) {
|
||||
id = `${base}-${suffix++}`;
|
||||
}
|
||||
used.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function normalizeSidebarLayout(value: unknown): SidebarLayout {
|
||||
if (!isRecord(value) || !Array.isArray(value.columns)) {
|
||||
return { columns: [] };
|
||||
}
|
||||
const usedColumnIds = new Set<string>();
|
||||
const usedPanelIds = new Set<string>();
|
||||
const usedSlots = new Set<SidebarSlotId>();
|
||||
const columns: SidebarColumn[] = [];
|
||||
for (const rawColumn of value.columns) {
|
||||
if (
|
||||
!isRecord(rawColumn) ||
|
||||
(rawColumn.side !== "left" && rawColumn.side !== "right") ||
|
||||
!Array.isArray(rawColumn.panels)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const columnId = uniqueId(rawColumn.id, "column", usedColumnIds);
|
||||
const panels: SidebarPanel[] = [];
|
||||
const panelIds = new Map<string, string>();
|
||||
for (const rawPanel of rawColumn.panels) {
|
||||
if (!isRecord(rawPanel) || !isSlotId(rawPanel.slot) || usedSlots.has(rawPanel.slot)) {
|
||||
continue;
|
||||
}
|
||||
const rawPanelId = typeof rawPanel.id === "string" ? rawPanel.id.trim() : "";
|
||||
const panelId = uniqueId(rawPanel.id, rawPanel.slot, usedPanelIds);
|
||||
const sourceId = rawPanelId || rawPanel.slot;
|
||||
if (!panelIds.has(sourceId)) {
|
||||
panelIds.set(sourceId, panelId);
|
||||
}
|
||||
usedSlots.add(rawPanel.slot);
|
||||
panels.push({ id: panelId, slot: rawPanel.slot });
|
||||
}
|
||||
if (panels.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const requestedActiveId =
|
||||
typeof rawColumn.activePanelId === "string" ? rawColumn.activePanelId.trim() : "";
|
||||
const activePanelId = panelIds.get(requestedActiveId) ?? panels[0]!.id;
|
||||
const fallbackWidth = panels.some((panel) => panel.slot === "chat")
|
||||
? CHAT_DEFAULT_WIDTH
|
||||
: DEFAULT_WIDTH;
|
||||
const width =
|
||||
typeof rawColumn.width === "number" && Number.isFinite(rawColumn.width)
|
||||
? clampWidth(rawColumn.width)
|
||||
: fallbackWidth;
|
||||
columns.push({ id: columnId, side: rawColumn.side, panels, activePanelId, width });
|
||||
}
|
||||
return { columns };
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canonicalUiSessionKeyForPersistence } from "../../lib/sessions/session-key.ts";
|
||||
import {
|
||||
normalizeSidebarSessionActivePanels,
|
||||
normalizeSidebarSessionLayouts,
|
||||
type SidebarSessionLayouts,
|
||||
updateSidebarSessionActivePanel,
|
||||
updateSidebarSessionLayout,
|
||||
} from "./sidebar-layout-persistence.ts";
|
||||
import { openSlot } from "./sidebar-layout.ts";
|
||||
|
||||
describe("sidebar session layout settings", () => {
|
||||
it("uses one persistence key for configured main-session aliases", () => {
|
||||
const host = {
|
||||
agentsList: { defaultId: "main", mainKey: "main" },
|
||||
hello: {
|
||||
snapshot: {
|
||||
sessionDefaults: {
|
||||
defaultAgentId: "main",
|
||||
mainKey: "main",
|
||||
mainSessionKey: "agent:main:current",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
expect(canonicalUiSessionKeyForPersistence(host, "main")).toBe("agent:main:current");
|
||||
expect(canonicalUiSessionKeyForPersistence(host, "agent:main:main")).toBe("agent:main:current");
|
||||
});
|
||||
|
||||
it("normalizes every persisted session layout", () => {
|
||||
expect(
|
||||
normalizeSidebarSessionLayouts({
|
||||
main: openSlot({ columns: [] }, "detail"),
|
||||
broken: { columns: "nope" },
|
||||
"": openSlot({ columns: [] }, "discussion"),
|
||||
}),
|
||||
).toEqual({
|
||||
main: openSlot({ columns: [] }, "detail"),
|
||||
broken: { columns: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it("caps the newest session layouts", () => {
|
||||
let layouts: SidebarSessionLayouts = {};
|
||||
for (let index = 0; index < 55; index += 1) {
|
||||
layouts = updateSidebarSessionLayout(
|
||||
layouts,
|
||||
`session-${index}`,
|
||||
openSlot({ columns: [] }, "discussion"),
|
||||
);
|
||||
}
|
||||
expect(Object.keys(layouts)).toHaveLength(50);
|
||||
expect(layouts["session-0"]).toBeUndefined();
|
||||
expect(layouts["session-54"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("normalizes and caps collapsed active-panel selections", () => {
|
||||
let selections = normalizeSidebarSessionActivePanels({
|
||||
main: " discussion ",
|
||||
broken: 42,
|
||||
"": "detail",
|
||||
});
|
||||
expect(selections).toEqual({ main: "discussion" });
|
||||
|
||||
for (let index = 0; index < 55; index += 1) {
|
||||
selections = updateSidebarSessionActivePanel(
|
||||
selections,
|
||||
`session-${index}`,
|
||||
`panel-${index}`,
|
||||
);
|
||||
}
|
||||
expect(Object.keys(selections)).toHaveLength(50);
|
||||
expect(selections["session-0"]).toBeUndefined();
|
||||
expect(selections["session-54"]).toBe("panel-54");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { normalizeSidebarLayout } from "./sidebar-layout-normalize.ts";
|
||||
import type { SidebarLayout } from "./sidebar-layout.ts";
|
||||
|
||||
export type SidebarSessionLayouts = Record<string, SidebarLayout>;
|
||||
export type SidebarSessionActivePanels = Record<string, string>;
|
||||
|
||||
const MAX_SIDEBAR_SESSION_LAYOUTS = 50;
|
||||
|
||||
export function normalizeSidebarSessionLayouts(value: unknown): SidebarSessionLayouts {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
const layouts: SidebarSessionLayouts = {};
|
||||
for (const [sessionKey, rawLayout] of Object.entries(value).slice(-MAX_SIDEBAR_SESSION_LAYOUTS)) {
|
||||
const key = sessionKey.trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
layouts[key] = normalizeSidebarLayout(rawLayout);
|
||||
}
|
||||
return layouts;
|
||||
}
|
||||
|
||||
export function updateSidebarSessionLayout(
|
||||
current: SidebarSessionLayouts | undefined,
|
||||
sessionKey: string,
|
||||
layout: SidebarLayout,
|
||||
): SidebarSessionLayouts {
|
||||
const key = sessionKey.trim();
|
||||
const layouts = normalizeSidebarSessionLayouts(current);
|
||||
if (!key) {
|
||||
return layouts;
|
||||
}
|
||||
delete layouts[key];
|
||||
layouts[key] = normalizeSidebarLayout(layout);
|
||||
return Object.fromEntries(Object.entries(layouts).slice(-MAX_SIDEBAR_SESSION_LAYOUTS));
|
||||
}
|
||||
|
||||
export function normalizeSidebarSessionActivePanels(value: unknown): SidebarSessionActivePanels {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
const selections: SidebarSessionActivePanels = {};
|
||||
for (const [sessionKey, panelId] of Object.entries(value).slice(-MAX_SIDEBAR_SESSION_LAYOUTS)) {
|
||||
const key = sessionKey.trim();
|
||||
const id = typeof panelId === "string" ? panelId.trim() : "";
|
||||
if (key && id) {
|
||||
selections[key] = id;
|
||||
}
|
||||
}
|
||||
return selections;
|
||||
}
|
||||
|
||||
export function updateSidebarSessionActivePanel(
|
||||
current: SidebarSessionActivePanels | undefined,
|
||||
sessionKey: string,
|
||||
panelId: string,
|
||||
): SidebarSessionActivePanels {
|
||||
const key = sessionKey.trim();
|
||||
const id = panelId.trim();
|
||||
const selections = normalizeSidebarSessionActivePanels(current);
|
||||
if (!key || !id) {
|
||||
return selections;
|
||||
}
|
||||
delete selections[key];
|
||||
selections[key] = id;
|
||||
return Object.fromEntries(Object.entries(selections).slice(-MAX_SIDEBAR_SESSION_LAYOUTS));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type SidebarSlotId = "chat" | "discussion" | "detail";
|
||||
export type SidebarSide = "left" | "right";
|
||||
export type SidebarPanel = { id: string; slot: SidebarSlotId };
|
||||
export type SidebarColumn = {
|
||||
id: string;
|
||||
side: SidebarSide;
|
||||
panels: SidebarPanel[];
|
||||
activePanelId: string;
|
||||
width: number;
|
||||
};
|
||||
export type SidebarLayout = { columns: SidebarColumn[] };
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
SIDEBAR_MIN_WIDTH_PX,
|
||||
activatePanel,
|
||||
closeSlot,
|
||||
detachPanelToColumn,
|
||||
fitSidebarLayout,
|
||||
mergePanelIntoColumn,
|
||||
normalizeSidebarLayout,
|
||||
openSlot,
|
||||
resizeColumn,
|
||||
type SidebarLayout,
|
||||
} from "./sidebar-layout.ts";
|
||||
|
||||
function openAll(): SidebarLayout {
|
||||
return openSlot(openSlot(openSlot({ columns: [] }, "discussion"), "chat"), "detail");
|
||||
}
|
||||
|
||||
describe("sidebar layout", () => {
|
||||
it("opens each slot in its own ranked column", () => {
|
||||
const layout = openAll();
|
||||
expect(layout.columns.map((column) => column.panels[0]?.slot)).toEqual([
|
||||
"chat",
|
||||
"detail",
|
||||
"discussion",
|
||||
]);
|
||||
expect(layout.columns.every((column) => column.panels.length === 1)).toBe(true);
|
||||
expect(layout.columns[2]?.panels[0]?.slot).toBe("discussion");
|
||||
expect(layout.columns[0]?.width).toBe(480);
|
||||
expect(layout.columns[1]?.width).toBe(360);
|
||||
});
|
||||
|
||||
it("merges panels into a tabbed column and activates the moved panel", () => {
|
||||
const layout = openAll();
|
||||
const detail = layout.columns[1]?.panels[0];
|
||||
const discussionColumn = layout.columns[2];
|
||||
expect(detail).toBeDefined();
|
||||
expect(discussionColumn).toBeDefined();
|
||||
const merged = mergePanelIntoColumn(layout, detail!.id, discussionColumn!.id, 0);
|
||||
expect(merged.columns).toHaveLength(2);
|
||||
expect(merged.columns[1]?.panels.map((panel) => panel.slot)).toEqual(["detail", "discussion"]);
|
||||
expect(merged.columns[1]?.activePanelId).toBe(detail!.id);
|
||||
expect(activatePanel(merged, discussionColumn!.activePanelId).columns[1]?.activePanelId).toBe(
|
||||
discussionColumn!.activePanelId,
|
||||
);
|
||||
});
|
||||
|
||||
it("detaches a tab back into its own column", () => {
|
||||
const layout = openAll();
|
||||
const detail = layout.columns[1]?.panels[0];
|
||||
const target = layout.columns[2];
|
||||
const merged = mergePanelIntoColumn(layout, detail!.id, target!.id, 0);
|
||||
const detached = detachPanelToColumn(merged, detail!.id, "right", 1);
|
||||
expect(detached.columns.map((column) => column.panels.map((panel) => panel.slot))).toEqual([
|
||||
["chat"],
|
||||
["detail"],
|
||||
["discussion"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("adjusts a later same-side boundary after removing the source column", () => {
|
||||
const layout = openAll();
|
||||
const chat = layout.columns[0]!.panels[0]!;
|
||||
const moved = detachPanelToColumn(layout, chat.id, "right", 2);
|
||||
expect(moved.columns.map((column) => column.panels[0]?.slot)).toEqual([
|
||||
"detail",
|
||||
"chat",
|
||||
"discussion",
|
||||
]);
|
||||
});
|
||||
|
||||
it("adjusts insertion indexes when reordering tabs within one column", () => {
|
||||
const layout = openAll();
|
||||
const detail = layout.columns[1]!.panels[0]!;
|
||||
const discussionColumn = layout.columns[2]!;
|
||||
const merged = mergePanelIntoColumn(layout, detail.id, discussionColumn.id, 0);
|
||||
const movedAfter = mergePanelIntoColumn(merged, detail.id, discussionColumn.id, 2);
|
||||
expect(movedAfter.columns[1]?.panels.map((panel) => panel.slot)).toEqual([
|
||||
"discussion",
|
||||
"detail",
|
||||
]);
|
||||
const movedBefore = mergePanelIntoColumn(movedAfter, detail.id, discussionColumn.id, 0);
|
||||
expect(movedBefore.columns[1]?.panels.map((panel) => panel.slot)).toEqual([
|
||||
"detail",
|
||||
"discussion",
|
||||
]);
|
||||
});
|
||||
|
||||
it("clamps resized widths at both ends", () => {
|
||||
const layout = openSlot({ columns: [] }, "detail");
|
||||
const columnId = layout.columns[0]!.id;
|
||||
expect(resizeColumn(layout, columnId, 1).columns[0]?.width).toBe(SIDEBAR_MIN_WIDTH_PX);
|
||||
expect(resizeColumn(layout, columnId, Number.MAX_VALUE).columns[0]?.width).toBe(1_200);
|
||||
});
|
||||
|
||||
it("shrinks the widest columns before refusing a region that cannot fit minimums", () => {
|
||||
const fitted = fitSidebarLayout(openAll(), 1_200);
|
||||
expect(fitted).not.toBeNull();
|
||||
expect(fitted!.columns.reduce((sum, column) => sum + column.width, 0)).toBe(876);
|
||||
expect(fitSidebarLayout(openAll(), 1_000)).toBeNull();
|
||||
});
|
||||
|
||||
it("removes a column when its last panel closes", () => {
|
||||
expect(closeSlot(openSlot({ columns: [] }, "detail"), "detail")).toEqual({ columns: [] });
|
||||
});
|
||||
|
||||
it("allocates a unique panel id when persisted ids collide with a slot", () => {
|
||||
const layout = normalizeSidebarLayout({
|
||||
columns: [
|
||||
{
|
||||
id: "detail-column",
|
||||
side: "right",
|
||||
panels: [{ id: "chat", slot: "detail" }],
|
||||
activePanelId: "chat",
|
||||
width: 360,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(openSlot(layout, "chat").columns[0]?.panels[0]?.id).toBe("chat-2");
|
||||
});
|
||||
|
||||
it("preserves the active tab when a missing panel id uses its slot fallback", () => {
|
||||
const layout = normalizeSidebarLayout({
|
||||
columns: [
|
||||
{
|
||||
id: "tabs",
|
||||
side: "right",
|
||||
panels: [{ id: "detail", slot: "detail" }, { slot: "discussion" }],
|
||||
activePanelId: "discussion",
|
||||
width: 360,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(layout.columns[0]?.activePanelId).toBe("discussion");
|
||||
});
|
||||
|
||||
it("round-trips valid layouts and rejects or repairs untrusted values", () => {
|
||||
const valid = openAll();
|
||||
expect(normalizeSidebarLayout(valid)).toEqual(valid);
|
||||
expect(normalizeSidebarLayout(null)).toEqual({ columns: [] });
|
||||
expect(normalizeSidebarLayout({ columns: "nope" })).toEqual({ columns: [] });
|
||||
expect(
|
||||
normalizeSidebarLayout({
|
||||
columns: [
|
||||
{
|
||||
id: "same",
|
||||
side: "right",
|
||||
panels: [
|
||||
{ id: "same-panel", slot: "detail" },
|
||||
{ id: "unknown", slot: "unknown" },
|
||||
],
|
||||
activePanelId: "missing",
|
||||
width: 20,
|
||||
},
|
||||
{
|
||||
id: "same",
|
||||
side: "right",
|
||||
panels: [
|
||||
{ id: "same-panel", slot: "discussion" },
|
||||
{ id: "duplicate-slot", slot: "detail" },
|
||||
],
|
||||
activePanelId: "same-panel",
|
||||
width: 50_000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
columns: [
|
||||
{
|
||||
id: "same",
|
||||
side: "right",
|
||||
panels: [{ id: "same-panel", slot: "detail" }],
|
||||
activePanelId: "same-panel",
|
||||
width: SIDEBAR_MIN_WIDTH_PX,
|
||||
},
|
||||
{
|
||||
id: "same-2",
|
||||
side: "right",
|
||||
panels: [{ id: "same-panel-2", slot: "discussion" }],
|
||||
activePanelId: "same-panel-2",
|
||||
width: 1_200,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import type {
|
||||
SidebarColumn,
|
||||
SidebarLayout,
|
||||
SidebarPanel,
|
||||
SidebarSide,
|
||||
SidebarSlotId,
|
||||
} from "./sidebar-layout-types.ts";
|
||||
|
||||
export type {
|
||||
SidebarColumn,
|
||||
SidebarLayout,
|
||||
SidebarPanel,
|
||||
SidebarSide,
|
||||
SidebarSlotId,
|
||||
} from "./sidebar-layout-types.ts";
|
||||
|
||||
const SIDEBAR_DEFAULT_WIDTH_PX = 360;
|
||||
const SIDEBAR_CHAT_DEFAULT_WIDTH_PX = 480;
|
||||
export const SIDEBAR_MIN_WIDTH_PX = 260;
|
||||
const SIDEBAR_MAX_WIDTH_PX = 1_200;
|
||||
const SIDEBAR_MAIN_MIN_WIDTH_PX = 312;
|
||||
export const SIDEBAR_NARROW_BREAKPOINT_PX = 680;
|
||||
const SIDEBAR_DIVIDER_WIDTH_PX = 4;
|
||||
|
||||
const SLOT_RANK: Record<SidebarSlotId, number> = {
|
||||
chat: 0,
|
||||
detail: 1,
|
||||
discussion: 2,
|
||||
};
|
||||
|
||||
function cloneLayout(layout: SidebarLayout): SidebarLayout {
|
||||
return structuredClone(layout);
|
||||
}
|
||||
|
||||
function clampWidth(width: number): number {
|
||||
return Math.min(SIDEBAR_MAX_WIDTH_PX, Math.max(SIDEBAR_MIN_WIDTH_PX, width));
|
||||
}
|
||||
|
||||
function defaultWidth(slot: SidebarSlotId): number {
|
||||
return slot === "chat" ? SIDEBAR_CHAT_DEFAULT_WIDTH_PX : SIDEBAR_DEFAULT_WIDTH_PX;
|
||||
}
|
||||
|
||||
function columnRank(column: SidebarColumn): number {
|
||||
return Math.min(...column.panels.map((panel) => SLOT_RANK[panel.slot]));
|
||||
}
|
||||
|
||||
function nextColumnId(layout: SidebarLayout, panelId: string): string {
|
||||
const base = `${panelId}-column`;
|
||||
const used = new Set(layout.columns.map((column) => column.id));
|
||||
if (!used.has(base)) {
|
||||
return base;
|
||||
}
|
||||
let suffix = 2;
|
||||
while (used.has(`${base}-${suffix}`)) {
|
||||
suffix += 1;
|
||||
}
|
||||
return `${base}-${suffix}`;
|
||||
}
|
||||
|
||||
function nextPanelId(layout: SidebarLayout, slot: SidebarSlotId): string {
|
||||
const used = new Set(layout.columns.flatMap((column) => column.panels.map((panel) => panel.id)));
|
||||
if (!used.has(slot)) {
|
||||
return slot;
|
||||
}
|
||||
let suffix = 2;
|
||||
while (used.has(`${slot}-${suffix}`)) {
|
||||
suffix += 1;
|
||||
}
|
||||
return `${slot}-${suffix}`;
|
||||
}
|
||||
|
||||
function sideInsertIndex(layout: SidebarLayout, side: SidebarSide, sideIndex: number): number {
|
||||
const indexes = layout.columns.flatMap((column, index) => (column.side === side ? [index] : []));
|
||||
if (indexes.length === 0) {
|
||||
const firstRight = layout.columns.findIndex((column) => column.side === "right");
|
||||
return side === "left" && firstRight >= 0 ? firstRight : layout.columns.length;
|
||||
}
|
||||
const clamped = Math.max(0, Math.min(sideIndex, indexes.length));
|
||||
return clamped === indexes.length ? (indexes.at(-1) ?? -1) + 1 : (indexes[clamped] ?? 0);
|
||||
}
|
||||
|
||||
function removePanel(layout: SidebarLayout, panelId: string): SidebarPanel | null {
|
||||
for (let columnIndex = 0; columnIndex < layout.columns.length; columnIndex += 1) {
|
||||
const column = layout.columns[columnIndex]!;
|
||||
const panelIndex = column.panels.findIndex((panel) => panel.id === panelId);
|
||||
if (panelIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
const panel = column.panels.splice(panelIndex, 1)[0]!;
|
||||
if (column.panels.length === 0) {
|
||||
layout.columns.splice(columnIndex, 1);
|
||||
} else if (column.activePanelId === panelId) {
|
||||
column.activePanelId =
|
||||
column.panels[Math.min(panelIndex, column.panels.length - 1)]?.id ?? "";
|
||||
}
|
||||
return panel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function openSlot(
|
||||
layout: SidebarLayout,
|
||||
slot: SidebarSlotId,
|
||||
side: SidebarSide = "right",
|
||||
): SidebarLayout {
|
||||
const next = cloneLayout(layout);
|
||||
if (next.columns.some((column) => column.panels.some((panel) => panel.slot === slot))) {
|
||||
return next;
|
||||
}
|
||||
const panel: SidebarPanel = { id: nextPanelId(next, slot), slot };
|
||||
const column: SidebarColumn = {
|
||||
id: nextColumnId(next, panel.id),
|
||||
side,
|
||||
panels: [panel],
|
||||
activePanelId: panel.id,
|
||||
width: defaultWidth(slot),
|
||||
};
|
||||
const sameSide = next.columns.filter((entry) => entry.side === side);
|
||||
const rankedIndex = sameSide.findIndex((entry) => columnRank(entry) > SLOT_RANK[slot]);
|
||||
const sideIndex = rankedIndex >= 0 ? rankedIndex : sameSide.length;
|
||||
next.columns.splice(sideInsertIndex(next, side, sideIndex), 0, column);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function closeSlot(layout: SidebarLayout, slot: SidebarSlotId): SidebarLayout {
|
||||
const next = cloneLayout(layout);
|
||||
const panel = next.columns
|
||||
.flatMap((column) => column.panels)
|
||||
.find((entry) => entry.slot === slot);
|
||||
if (panel) {
|
||||
removePanel(next, panel.id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function activatePanel(layout: SidebarLayout, panelId: string): SidebarLayout {
|
||||
const next = cloneLayout(layout);
|
||||
const column = next.columns.find((entry) => entry.panels.some((panel) => panel.id === panelId));
|
||||
if (column) {
|
||||
column.activePanelId = panelId;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function mergePanelIntoColumn(
|
||||
layout: SidebarLayout,
|
||||
panelId: string,
|
||||
targetColumnId: string,
|
||||
index: number,
|
||||
): SidebarLayout {
|
||||
const next = cloneLayout(layout);
|
||||
const source = next.columns.find((column) => column.panels.some((panel) => panel.id === panelId));
|
||||
const sourceIndex = source?.panels.findIndex((panel) => panel.id === panelId) ?? -1;
|
||||
const sameColumn = source?.id === targetColumnId;
|
||||
const panel = removePanel(next, panelId);
|
||||
const target = next.columns.find((column) => column.id === targetColumnId);
|
||||
if (!panel || !target) {
|
||||
return cloneLayout(layout);
|
||||
}
|
||||
const requestedIndex = Math.trunc(index) - (sameColumn && sourceIndex < index ? 1 : 0);
|
||||
const insertIndex = Math.max(0, Math.min(requestedIndex, target.panels.length));
|
||||
target.panels.splice(insertIndex, 0, panel);
|
||||
target.activePanelId = panel.id;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function detachPanelToColumn(
|
||||
layout: SidebarLayout,
|
||||
panelId: string,
|
||||
side: SidebarSide,
|
||||
columnIndex: number,
|
||||
): SidebarLayout {
|
||||
const next = cloneLayout(layout);
|
||||
const source = next.columns.find((column) => column.panels.some((panel) => panel.id === panelId));
|
||||
const sourceSideIndex = source
|
||||
? next.columns.filter((column) => column.side === source.side).indexOf(source)
|
||||
: -1;
|
||||
const removesSourceColumn = source?.panels.length === 1;
|
||||
const sourceWidth = source?.width ?? SIDEBAR_DEFAULT_WIDTH_PX;
|
||||
const panel = removePanel(next, panelId);
|
||||
if (!panel) {
|
||||
return next;
|
||||
}
|
||||
const column: SidebarColumn = {
|
||||
id: nextColumnId(next, panel.id),
|
||||
side,
|
||||
panels: [panel],
|
||||
activePanelId: panel.id,
|
||||
width: sourceWidth,
|
||||
};
|
||||
const requestedIndex =
|
||||
source?.side === side && removesSourceColumn && sourceSideIndex < columnIndex
|
||||
? columnIndex - 1
|
||||
: columnIndex;
|
||||
next.columns.splice(sideInsertIndex(next, side, Math.trunc(requestedIndex)), 0, column);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function resizeColumn(
|
||||
layout: SidebarLayout,
|
||||
columnId: string,
|
||||
width: number,
|
||||
): SidebarLayout {
|
||||
const next = cloneLayout(layout);
|
||||
const column = next.columns.find((entry) => entry.id === columnId);
|
||||
if (column && Number.isFinite(width)) {
|
||||
column.width = clampWidth(width);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function fitSidebarLayout(
|
||||
layout: SidebarLayout,
|
||||
availableWidth: number,
|
||||
newestColumnId?: string,
|
||||
): SidebarLayout | null {
|
||||
const next = cloneLayout(layout);
|
||||
if (!Number.isFinite(availableWidth) || availableWidth <= 0) {
|
||||
return next;
|
||||
}
|
||||
const maxColumnWidth = Math.max(
|
||||
SIDEBAR_MIN_WIDTH_PX,
|
||||
Math.min(SIDEBAR_MAX_WIDTH_PX, availableWidth * 0.6),
|
||||
);
|
||||
for (const column of next.columns) {
|
||||
column.width = Math.min(maxColumnWidth, clampWidth(column.width));
|
||||
}
|
||||
const budget = Math.max(
|
||||
0,
|
||||
availableWidth - SIDEBAR_MAIN_MIN_WIDTH_PX - next.columns.length * SIDEBAR_DIVIDER_WIDTH_PX,
|
||||
);
|
||||
if (next.columns.length * SIDEBAR_MIN_WIDTH_PX > budget) {
|
||||
return null;
|
||||
}
|
||||
let excess = next.columns.reduce((sum, column) => sum + column.width, 0) - budget;
|
||||
const shrinkOrder = next.columns.toSorted((left, right) => {
|
||||
const newestOrder = Number(left.id === newestColumnId) - Number(right.id === newestColumnId);
|
||||
return newestOrder || right.width - left.width;
|
||||
});
|
||||
for (const column of shrinkOrder) {
|
||||
if (excess <= 0) {
|
||||
break;
|
||||
}
|
||||
const shrink = Math.min(excess, column.width - SIDEBAR_MIN_WIDTH_PX);
|
||||
column.width -= shrink;
|
||||
excess -= shrink;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function isSidebarRegionCollapsed(layout: SidebarLayout, availableWidth: number): boolean {
|
||||
return (
|
||||
availableWidth < SIDEBAR_NARROW_BREAKPOINT_PX ||
|
||||
SIDEBAR_MAIN_MIN_WIDTH_PX +
|
||||
layout.columns.length * (SIDEBAR_MIN_WIDTH_PX + SIDEBAR_DIVIDER_WIDTH_PX) >
|
||||
availableWidth
|
||||
);
|
||||
}
|
||||
|
||||
export function sidebarPrimaryWidth(layout: SidebarLayout, availableWidth: number): number {
|
||||
const sidebarWidth = layout.columns.reduce((sum, column) => sum + column.width, 0);
|
||||
return Math.max(
|
||||
SIDEBAR_MAIN_MIN_WIDTH_PX,
|
||||
availableWidth - sidebarWidth - layout.columns.length * SIDEBAR_DIVIDER_WIDTH_PX,
|
||||
);
|
||||
}
|
||||
|
||||
export { normalizeSidebarLayout } from "./sidebar-layout-normalize.ts";
|
||||
@@ -20,7 +20,6 @@ function createConnectionProps(overrides: Partial<ConnectionProps> = {}): Connec
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 258,
|
||||
sidebarEntries: [],
|
||||
|
||||
@@ -55,18 +55,6 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.board-session-surface--dock-left .board-session-surface__chat {
|
||||
order: 0;
|
||||
}
|
||||
|
||||
.board-session-surface--dock-left .board-session-surface__divider {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.board-session-surface--dock-left .board-session-surface__board {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.board-session-surface__board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -160,7 +148,6 @@ openclaw-workboard-card-chip {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
max-width: 80%;
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
}
|
||||
@@ -176,8 +163,6 @@ openclaw-workboard-card-chip {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.board-session-surface--dock-hidden .board-session-surface__chat,
|
||||
.board-session-surface--dock-hidden .board-session-surface__divider,
|
||||
.board-session-surface__reopen[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
+166
-63
@@ -170,6 +170,7 @@
|
||||
overflow: hidden;
|
||||
/* Smooth transition when sidebar opens/closes */
|
||||
transition: flex 250ms ease-out;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.chat-main__conversation {
|
||||
@@ -194,17 +195,175 @@
|
||||
flex: 0 0 400px;
|
||||
}
|
||||
|
||||
/* No border-left: the resizable divider rendered next to this panel draws
|
||||
the separator line; a border here doubles it. */
|
||||
.chat-sidebar {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
openclaw-chat-sidebar-region,
|
||||
.sidebar-region__right-runtime,
|
||||
.sidebar-region__panels-runtime {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.sidebar-region {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-region__primary {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
min-width: 312px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-column {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
min-width: 260px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
flex-direction: column;
|
||||
animation: chat-sidebar-fade-in 160ms ease-out;
|
||||
}
|
||||
|
||||
.sidebar-column__header {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--panel-strong) 90%, transparent);
|
||||
}
|
||||
|
||||
.sidebar-column__tabs {
|
||||
--track-width: 0;
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
align-self: stretch;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.sidebar-column__tabs::part(body) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-column__tabs::part(nav) {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar-column__tab {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.sidebar-column__tab::part(base) {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding: 0 12px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-column__tab[active]::part(base) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar-column__tab[active]::part(base)::after {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 0;
|
||||
left: 10px;
|
||||
height: 2px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.sidebar-column__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
.sidebar-column__actions .btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.sidebar-column__body,
|
||||
.sidebar-column__panel,
|
||||
.sidebar-column__panel > :is(.chat, openclaw-chat-detail-panel, openclaw-session-discussion) {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-column__panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-column__panel--wide {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 42px;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.sidebar-column__panel > openclaw-chat-detail-panel {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-column__divider {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.sidebar-region--narrow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(200px, 1fr) minmax(160px, 1fr);
|
||||
}
|
||||
|
||||
.sidebar-region--narrow .sidebar-region__primary {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
min-width: 0;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.sidebar-column__panel--narrow {
|
||||
z-index: 1;
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
min-height: 0;
|
||||
margin-top: 42px;
|
||||
}
|
||||
|
||||
.sidebar-column--collapsed {
|
||||
z-index: 0;
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 160px;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.chat-workspace-rail {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
@@ -1142,23 +1301,9 @@
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sidebar-content--discussion {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Host wrapper for full-height panel kinds: the discussion iframe has no
|
||||
intrinsic height, so the wrapper must stretch for height:100% to resolve. */
|
||||
.sidebar-panel-host--fill {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
openclaw-session-discussion {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -1652,23 +1797,6 @@ openclaw-session-discussion {
|
||||
}
|
||||
}
|
||||
|
||||
/* Narrow pane: the detail panel stacks under the thread instead of fighting
|
||||
it for horizontal space (chat-pane measures the pane and sets the class;
|
||||
the divider flips to a horizontal handle so the split stays resizable). */
|
||||
.chat-split-container--stacked {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-split-container--stacked .chat-main {
|
||||
min-width: 0;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.chat-split-container--stacked .chat-sidebar {
|
||||
min-width: 0;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
/* Narrow pane: the tasks rail mirrors the workspace rail's bottom strip.
|
||||
chat-view only sets --tasks-open for the side column, so the column
|
||||
templates never fire here; these rules add the strip row instead. */
|
||||
@@ -1718,31 +1846,6 @@ openclaw-session-discussion {
|
||||
}
|
||||
|
||||
/* Mobile: Full-screen modal */
|
||||
@media (max-width: 768px) {
|
||||
.chat-split-container--open {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.chat-split-container--open .chat-main {
|
||||
display: none; /* Hide chat on mobile when sidebar open */
|
||||
}
|
||||
|
||||
/* No split to resize while the chat column is hidden. */
|
||||
.chat-split-container--open resizable-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-split-container--open .chat-sidebar {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Session diff panel (sessions.diff sidebar content) ── */
|
||||
|
||||
.session-diff {
|
||||
|
||||
@@ -124,8 +124,6 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-
|
||||
/* A session dashboard keeps the board primary on narrow shells. Every desktop
|
||||
edge becomes one bounded bottom sheet; the stored tab preference is left
|
||||
untouched and returns when the viewport widens. */
|
||||
.shell--mobile-nav .board-session-surface--dock-left,
|
||||
.shell--mobile-nav .board-session-surface--dock-right,
|
||||
.shell--mobile-nav .board-session-surface--dock-bottom {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user