fix(control-ui): allow configured chat message width

Adds validated gateway.controlUi.chatMessageMaxWidth support for grouped Control UI chat messages, carries it through the Gateway bootstrap payload into UI state, applies it as a CSS custom property, and documents the setting while preserving the existing default width.

Fixes #67935.

Validation:
- Targeted config, gateway, and Control UI tests passed locally.
- Config schema/docs checks passed.
- Testbox changed-file gate passed.
- GitHub CI and security checks are green on cea25a4ca9.
This commit is contained in:
Val Alexander
2026-05-02 10:18:08 -05:00
committed by GitHub
parent a3564ae546
commit 5fce2f6b0f
23 changed files with 206 additions and 4 deletions

View File

@@ -24,7 +24,7 @@
gap: 2px;
flex: 1 1 auto;
width: 100%;
max-width: min(900px, 68%);
max-width: var(--chat-message-max-width, min(900px, 68%));
align-items: flex-start;
min-width: 0;
}

View File

@@ -20,6 +20,15 @@ function readLayoutCss(): string {
return readFileSync(cssPath!, "utf8");
}
function readGroupedChatCss(): string {
const cssPath = [
resolve(process.cwd(), "ui/src/styles/chat/grouped.css"),
resolve(process.cwd(), "..", "ui/src/styles/chat/grouped.css"),
].find((candidate) => existsSync(candidate));
expect(cssPath).toBeTruthy();
return readFileSync(cssPath!, "utf8");
}
describe("chat header responsive mobile styles", () => {
it("keeps the chat header and session controls from clipping on narrow widths", () => {
const css = readMobileCss();
@@ -46,3 +55,11 @@ describe("sidebar menu trigger styles", () => {
expect(css).toContain("display: none;");
});
});
describe("grouped chat width styles", () => {
it("uses the config-fed CSS variable with the current fallback", () => {
const css = readGroupedChatCss();
expect(css).toContain("max-width: var(--chat-message-max-width, min(900px, 68%));");
});
});

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { html } from "lit";
import { html, render } from "lit";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AppViewState } from "./app-view-state.ts";
import type { QuickSettingsProps } from "./views/config-quick.ts";
@@ -89,6 +89,7 @@ function createState(overrides: Partial<AppViewState> = {}): AppViewState {
localMediaPreviewRoots: [],
embedSandboxMode: "scripts",
allowExternalEmbedUrls: false,
chatMessageMaxWidth: null,
sessionKey: "main",
chatLoading: false,
chatSending: false,
@@ -227,4 +228,16 @@ describe("renderApp assistant avatar routing", () => {
expect(quickSettingsProps.current?.assistantAvatarReason).toBeNull();
expect(quickSettingsProps.current?.assistantAvatarOverride).toBe(dataUrl);
});
it("applies the configured chat message width as a shell CSS variable", () => {
const container = document.createElement("div");
render(
renderApp(createState({ tab: "chat", chatMessageMaxWidth: "min(1280px, 82%)" })),
container,
);
const shell = container.querySelector<HTMLElement>(".shell");
expect(shell?.style.getPropertyValue("--chat-message-max-width")).toBe("min(1280px, 82%)");
});
});

View File

@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
import { styleMap } from "lit/directives/style-map.js";
import { t } from "../i18n/index.ts";
import { getSafeLocalStorage } from "../local-storage.ts";
import { refreshChat } from "./app-chat.ts";
@@ -1329,6 +1330,9 @@ export function renderApp(state: AppViewState) {
: ""} ${navCollapsed ? "shell--nav-collapsed" : ""} ${navDrawerOpen
? "shell--nav-drawer-open"
: ""} ${state.onboarding ? "shell--onboarding" : ""}"
style=${styleMap(
state.chatMessageMaxWidth ? { "--chat-message-max-width": state.chatMessageMaxWidth } : {},
)}
>
<button
type="button"

View File

@@ -84,6 +84,7 @@ export type AppViewState = {
localMediaPreviewRoots: string[];
embedSandboxMode: EmbedSandboxMode;
allowExternalEmbedUrls: boolean;
chatMessageMaxWidth?: string | null;
sessionKey: string;
chatLoading: boolean;
chatSending: boolean;

View File

@@ -189,6 +189,7 @@ export class OpenClawApp extends LitElement {
@state() localMediaPreviewRoots: string[] = [];
@state() embedSandboxMode: "strict" | "scripts" | "trusted" = "scripts";
@state() allowExternalEmbedUrls = false;
@state() chatMessageMaxWidth: string | null = null;
@state() serverVersion: string | null = null;
@state() sessionKey = this.settings.sessionKey;

View File

@@ -20,6 +20,7 @@ describe("loadControlUiBootstrapConfig", () => {
localMediaPreviewRoots: ["/tmp/openclaw"],
embedSandbox: "scripts",
allowExternalEmbedUrls: true,
chatMessageMaxWidth: "min(1280px, 82%)",
}),
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
@@ -35,6 +36,7 @@ describe("loadControlUiBootstrapConfig", () => {
localMediaPreviewRoots: [],
embedSandboxMode: "scripts" as const,
allowExternalEmbedUrls: false,
chatMessageMaxWidth: null,
serverVersion: null,
};
@@ -54,6 +56,7 @@ describe("loadControlUiBootstrapConfig", () => {
expect(state.localMediaPreviewRoots).toEqual(["/tmp/openclaw"]);
expect(state.embedSandboxMode).toBe("scripts");
expect(state.allowExternalEmbedUrls).toBe(true);
expect(state.chatMessageMaxWidth).toBe("min(1280px, 82%)");
vi.unstubAllGlobals();
});

View File

@@ -22,6 +22,7 @@ export type ControlUiBootstrapState = {
localMediaPreviewRoots: string[];
embedSandboxMode: ControlUiEmbedSandboxMode;
allowExternalEmbedUrls: boolean;
chatMessageMaxWidth?: string | null;
sessionKey?: string | null;
hello?: { auth?: { deviceToken?: string | null } | null } | null;
settings?: { token?: string | null } | null;
@@ -130,6 +131,10 @@ export async function loadControlUiBootstrapConfig(
? "strict"
: "scripts";
state.allowExternalEmbedUrls = parsed.allowExternalEmbedUrls === true;
state.chatMessageMaxWidth =
typeof parsed.chatMessageMaxWidth === "string" && parsed.chatMessageMaxWidth.trim()
? parsed.chatMessageMaxWidth
: null;
} catch {
// Ignore bootstrap failures; UI will update identity after connecting.
}