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:
Peter Steinberger
2026-07-25 17:45:29 -07:00
committed by GitHub
parent c539c77057
commit fb26903d6c
53 changed files with 2978 additions and 706 deletions
@@ -1,5 +1,5 @@
{
"startupJsGzipBytes": 319655,
"reason": "canvas capability renewal lease",
"startupJsGzipBytes": 319835,
"reason": "flexible multi-sidebar chat layout",
"updatedAt": "2026-07-25"
}
+29 -15
View File
@@ -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
View File
@@ -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 (240400px)
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,
+25 -6
View File
@@ -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);
}
+8
View File
@@ -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...",
-16
View File
@@ -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;
}
+7
View File
@@ -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,
+13 -13
View File
@@ -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();
});
});
+6 -7
View File
@@ -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}"
-1
View File
@@ -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"],
+9
View File
@@ -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 = "";
+91 -29
View File
@@ -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);
});
});
+130 -18
View File
@@ -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,
+21 -6
View File
@@ -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.
+18 -7
View File
@@ -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:
+61 -33
View File
@@ -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>
+1 -1
View File
@@ -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(),
+1
View File
@@ -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 = [];
+127 -50
View File
@@ -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()}`;
}
}
-4
View File
@@ -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();
});
});
+10 -1
View File
@@ -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;
+4 -3
View File
@@ -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);
}
-1
View File
@@ -395,7 +395,6 @@ function makeHost(overrides?: MakeHostOverrides): TestChatHost | TestChatHostWit
chatShowToolCalls: next.chatShowToolCalls,
chatPersistCommentary: next.chatPersistCommentary,
chatSendShortcut: next.chatSendShortcut,
splitRatio: next.splitRatio,
});
}),
...hostOverrides,
+1 -1
View File
@@ -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;
+6 -3
View File
@@ -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>;
+70 -13
View File
@@ -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;
}
+11 -4
View File
@@ -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(
+11
View File
@@ -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("");
+1 -84
View File
@@ -436,7 +436,6 @@ function createChatHeaderState(
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "dark",
splitRatio: 0.6,
navCollapsed: false,
navWidth: 280,
sidebarEntries: [],
@@ -652,9 +651,6 @@ function createChatProps(
error: null,
runError: null,
sessions: null,
sidebarOpen: false,
sidebarContent: null,
splitRatio: 0.6,
canvasPluginSurfaceUrl: null,
embedSandboxMode: "scripts",
allowExternalEmbedUrls: false,
@@ -691,8 +687,6 @@ function createChatProps(
onNavigateToAgent: () => undefined,
onSessionSelect: () => undefined,
onOpenSidebar: () => undefined,
onCloseSidebar: () => undefined,
onSplitRatioChange: () => undefined,
onChatScroll: () => undefined,
basePath: "",
...overrides,
@@ -1659,26 +1653,7 @@ describe("chat composer workbench", () => {
expect(container.querySelector('button[aria-label="Thread workspace"]')).toBeNull();
});
it("stacks the detail sidebar under the thread with a horizontal divider on narrow panes", () => {
const sidebarProps = {
sidebarOpen: true,
sidebarContent: { kind: "markdown", content: "Stacked detail" } as const,
onCloseSidebar: () => undefined,
};
const wide = renderChatView(sidebarProps);
const wideContainer = wide.querySelector(".chat-split-container");
expect(wideContainer?.classList.contains("chat-split-container--open")).toBe(true);
expect(wideContainer?.classList.contains("chat-split-container--stacked")).toBe(false);
// Attribute reflection is async; the property binding lands synchronously.
expect(wide.querySelector("resizable-divider")?.orientation).toBe("vertical");
const stacked = renderChatView({ ...sidebarProps, sidebarStacked: true });
const stackedContainer = stacked.querySelector(".chat-split-container");
expect(stackedContainer?.classList.contains("chat-split-container--stacked")).toBe(true);
expect(stacked.querySelector("resizable-divider")?.orientation).toBe("horizontal");
});
it("opens inline Markdown images and renders the active lightbox", () => {
it("opens inline Markdown images", () => {
const onOpenImage = vi.fn();
const src = "data:image/png;base64,cG5n";
const container = renderChatView({ onOpenImage });
@@ -1701,64 +1676,6 @@ describe("chat composer workbench", () => {
fallbackTrigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(openSpy).toHaveBeenCalledWith(src, "_blank", "noopener,noreferrer");
openSpy.mockRestore();
const onCloseImage = vi.fn();
const lightboxContainer = renderChatView({
imageLightbox: { src, title: "Artifact preview" },
onCloseImage,
});
const lightbox = lightboxContainer.querySelector("openclaw-image-lightbox");
expect(lightbox?.src).toBe(src);
expect(lightbox?.title).toBe("Artifact preview");
lightbox?.dispatchEvent(new CustomEvent("image-lightbox-close", { bubbles: true }));
expect(onCloseImage).toHaveBeenCalledTimes(1);
});
it("keeps lightbox Escape from clearing the pending reply", () => {
const onClearReply = vi.fn();
const container = renderChatView({
replyTarget: { messageId: "reply-1", text: "Keep this reply" },
onClearReply,
imageLightbox: {
src: "data:image/png;base64,cG5n",
title: "Artifact preview",
},
onCloseImage: vi.fn(),
});
const lightbox = container.querySelector("openclaw-image-lightbox");
lightbox?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, composed: true }),
);
expect(onClearReply).not.toHaveBeenCalled();
});
it("opens sidebar Markdown images once", async () => {
const onOpenImage = vi.fn();
const container = renderChatView({
sidebarOpen: true,
sidebarContent: {
kind: "markdown",
content: "![Preview](data:image/png;base64,cG5n)",
},
onCloseSidebar: vi.fn(),
onOpenImage,
});
document.body.append(container);
const panel = container.querySelector("openclaw-chat-detail-panel") as
| (Element & { updateComplete: Promise<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", () => {
+3 -56
View File
@@ -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();
});
});
+96 -153
View File
@@ -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));
}
+11
View File
@@ -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[] };
+186
View File
@@ -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,
},
],
});
});
});
+268
View File
@@ -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: [],
-15
View File
@@ -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
View File
@@ -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 {
-2
View File
@@ -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;
}