diff --git a/src/config/io.compat.test.ts b/src/config/io.compat.test.ts
index 2807dadd0dd6..f4f69a1165cb 100644
--- a/src/config/io.compat.test.ts
+++ b/src/config/io.compat.test.ts
@@ -1,13 +1,11 @@
// Verifies config IO compatibility loading and migration behavior.
import fs from "node:fs/promises";
import path from "node:path";
-import { beforeAll, describe, expect, it, vi } from "vitest";
-import { normalizeCompatibilityConfigValues } from "../commands/doctor/shared/legacy-config-core-migrate.js";
+import { describe, expect, it, vi } from "vitest";
import { VERSION } from "../version.js";
import { createConfigIO } from "./io.js";
import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin.js";
import { withTempHome } from "./test-helpers.js";
-import type { OpenClawConfig } from "./types.openclaw.js";
async function writeConfig(
home: string,
@@ -30,29 +28,6 @@ function createIoForHome(home: string, env: NodeJS.ProcessEnv = {} as NodeJS.Pro
}
describe("config io paths", () => {
- let whatsappSharedAccessDefaults: unknown;
-
- beforeAll(() => {
- const migrated = normalizeCompatibilityConfigValues({
- channels: {
- whatsapp: {
- enabled: true,
- dmPolicy: "allowlist",
- allowFrom: ["+15550001111"],
- groupPolicy: "open",
- groupAllowFrom: [],
- accounts: {
- work: {
- enabled: true,
- authDir: "/tmp/wa-work",
- },
- },
- },
- },
- } as OpenClawConfig);
- whatsappSharedAccessDefaults = migrated.config.channels?.whatsapp?.accounts?.default;
- });
-
it("uses ~/.openclaw/openclaw.json when config exists", async () => {
await withTempHome(async (home) => {
const configPath = await writeConfig(home, ".openclaw", 19001);
@@ -335,13 +310,4 @@ describe("config io paths", () => {
});
expect(cfg.agents?.list?.[0]?.tools?.exec?.safeBinTrustedDirs).toEqual(["/ops/bin"]);
});
-
- it("moves WhatsApp shared access defaults into accounts.default during runtime compat", () => {
- expect(whatsappSharedAccessDefaults).toEqual({
- dmPolicy: "allowlist",
- allowFrom: ["+15550001111"],
- groupPolicy: "open",
- groupAllowFrom: [],
- });
- });
});
diff --git a/ui/src/e2e/chat-markdown-alignment.e2e.test.ts b/ui/src/e2e/chat-markdown-alignment.e2e.test.ts
new file mode 100644
index 000000000000..2d4c715c475d
--- /dev/null
+++ b/ui/src/e2e/chat-markdown-alignment.e2e.test.ts
@@ -0,0 +1,284 @@
+import { expect, it } from "vitest";
+import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
+import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
+
+const suite = createControlUiE2eSuite({
+ name: "chat Markdown alignment",
+ unavailableMessage: (executablePath) => `Playwright Chromium is unavailable at ${executablePath}`,
+});
+
+suite.define(() => {
+ it("aligns Markdown markers and text while containing expanded disclosures", async () => {
+ const longJson = JSON.stringify(
+ Object.fromEntries(
+ Array.from({ length: 40 }, (_, index) => [`field-${index + 1}`, index + 1]),
+ ),
+ null,
+ 2,
+ );
+ await suite.withPage(
+ {
+ colorScheme: "light",
+ locale: "en-US",
+ serviceWorkers: "block",
+ viewport: { height: 800, width: 1180 },
+ },
+ async ({ page }) => {
+ await installMockGateway(page, {
+ historyMessages: [
+ {
+ content: [
+ {
+ type: "text",
+ text: [
+ "## Alignment check",
+ "",
+ "- Bullet item",
+ "",
+ "1. Numbered item",
+ "",
+ "- [ ] Unchecked task",
+ "",
+ "",
+ "More details
",
+ "",
+ "Disclosure body",
+ " ",
+ "",
+ "",
+ "Collapsed details
",
+ "Hidden body",
+ " ",
+ "",
+ "```json",
+ longJson,
+ "```",
+ ].join("\n"),
+ },
+ ],
+ role: "assistant",
+ timestamp: Date.now(),
+ },
+ ],
+ });
+
+ await page.goto(`${suite.server.baseUrl}chat`);
+ const markdown = page.locator(".chat-group.assistant .chat-text", {
+ hasText: "Alignment check",
+ });
+ await markdown.waitFor();
+
+ const geometry = await markdown.evaluate((root) => {
+ const textRect = (selector: string) => {
+ const element = root.querySelector(selector);
+ if (!element) {
+ throw new Error(`Missing element for ${selector}`);
+ }
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
+ let text = walker.nextNode();
+ while (text && !text.textContent?.trim()) {
+ text = walker.nextNode();
+ }
+ if (!text) {
+ throw new Error(`Missing text for ${selector}`);
+ }
+ const range = document.createRange();
+ range.selectNodeContents(text);
+ return range.getBoundingClientRect();
+ };
+ const checkbox = root.querySelector(".task-list-item-checkbox");
+ const details = root.querySelector("details:not(.json-collapse)[open]");
+ const summary = root.querySelector("details:not(.json-collapse)[open] > summary");
+ if (!checkbox || !details || !summary) {
+ throw new Error("Missing task-list or disclosure markup");
+ }
+ const detailsStyle = getComputedStyle(details);
+ const summaryStyle = getComputedStyle(summary);
+ const checkboxRect = checkbox.getBoundingClientRect();
+ const taskTextRect = textRect(".task-list-item");
+ const collapsedSummary = root.querySelector(
+ "details:not(.json-collapse):not([open]) > summary",
+ );
+ const jsonCollapse = root.querySelector("details.json-collapse");
+ const jsonSummary = root.querySelector("details.json-collapse > summary");
+ const jsonCopy = root.querySelector("details.json-collapse .code-block-copy");
+ if (!collapsedSummary || !jsonCollapse || !jsonSummary || !jsonCopy) {
+ throw new Error("Missing authored or JSON disclosure markup");
+ }
+ const closedChevronStyle = getComputedStyle(collapsedSummary, "::after");
+ const collapsedSummaryRect = collapsedSummary.getBoundingClientRect();
+ const collapsedSummaryTextRect = textRect(
+ "details:not(.json-collapse):not([open]) > summary",
+ );
+ const jsonCollapseStyle = getComputedStyle(jsonCollapse);
+ const jsonSummaryStyle = getComputedStyle(jsonSummary);
+ return {
+ bodyTextX: textRect("details:not(.json-collapse) > p").x,
+ borderInlineStartWidth: detailsStyle.borderInlineStartWidth,
+ bulletTextX: textRect("ul:not(.contains-task-list) > li").x,
+ checkboxGap: taskTextRect.x - checkboxRect.right,
+ checkboxLineCenterDelta:
+ checkboxRect.y + checkboxRect.height / 2 - (taskTextRect.y + taskTextRect.height / 2),
+ checkboxSize: checkboxRect.width,
+ chevronClosedTransform: closedChevronStyle.transform,
+ chevronInlineEnd: closedChevronStyle.insetInlineEnd,
+ chevronTransitionDuration: closedChevronStyle.transitionDuration,
+ chevronWidth: closedChevronStyle.width,
+ collapsedSummaryPaddingInlineEnd: getComputedStyle(collapsedSummary).paddingInlineEnd,
+ collapsedSummaryTextRight: collapsedSummaryTextRect.right,
+ collapsedSummaryTextX: collapsedSummaryTextRect.x,
+ collapsedSummaryRight: collapsedSummaryRect.right,
+ detailsX: details.getBoundingClientRect().x,
+ jsonBorderInlineStartWidth: jsonCollapseStyle.borderInlineStartWidth,
+ jsonCopyFloat: getComputedStyle(jsonCopy).float,
+ jsonDetailsX: jsonCollapse.getBoundingClientRect().x,
+ jsonSummaryDisplay: jsonSummaryStyle.display,
+ jsonSummaryPaddingInlineStart: jsonSummaryStyle.paddingInlineStart,
+ numberedTextX: textRect("ol > li").x,
+ rootX: root.getBoundingClientRect().x,
+ summaryMarginBottom: summaryStyle.marginBottom,
+ summaryTextX: textRect("details[open] > summary").x,
+ taskTextX: taskTextRect.x,
+ };
+ });
+
+ const textStarts = [
+ geometry.bulletTextX,
+ geometry.numberedTextX,
+ geometry.taskTextX,
+ geometry.summaryTextX,
+ geometry.collapsedSummaryTextX,
+ ];
+ expect(Math.max(...textStarts) - Math.min(...textStarts)).toBeLessThanOrEqual(1);
+ expect(geometry.checkboxGap).toBeGreaterThanOrEqual(7);
+ expect(geometry.checkboxGap).toBeLessThanOrEqual(9);
+ expect(Math.abs(geometry.checkboxLineCenterDelta)).toBeLessThanOrEqual(1);
+ expect(geometry.checkboxSize).toBe(16);
+ expect(geometry.bodyTextX - geometry.rootX).toBeGreaterThanOrEqual(28);
+ expect(geometry.detailsX).toBeGreaterThan(geometry.rootX);
+ expect(geometry.detailsX).toBeLessThan(geometry.bodyTextX);
+ expect(Number.parseFloat(geometry.borderInlineStartWidth)).toBeGreaterThan(0);
+ expect(Number.parseFloat(geometry.summaryMarginBottom)).toBeGreaterThan(0);
+ expect(Number.parseFloat(geometry.chevronWidth)).toBe(16);
+ expect(Number.parseFloat(geometry.chevronInlineEnd)).toBe(0);
+ expect(Number.parseFloat(geometry.collapsedSummaryPaddingInlineEnd)).toBeGreaterThanOrEqual(
+ 24,
+ );
+ expect(geometry.collapsedSummaryRight - geometry.collapsedSummaryTextRight).toBeGreaterThan(
+ 24,
+ );
+ expect(geometry.chevronTransitionDuration).not.toBe("0s");
+ expect(Math.abs(geometry.jsonDetailsX - geometry.rootX)).toBeLessThanOrEqual(1);
+ expect(Number.parseFloat(geometry.jsonBorderInlineStartWidth)).toBe(0);
+ expect(geometry.jsonSummaryDisplay).toBe("list-item");
+ expect(Number.parseFloat(geometry.jsonSummaryPaddingInlineStart)).toBe(8);
+ expect(geometry.jsonCopyFloat).toBe("right");
+
+ const collapsedSummary = markdown.locator("summary", { hasText: "Collapsed details" });
+ await collapsedSummary.click();
+ await expect
+ .poll(() =>
+ collapsedSummary.evaluate((summary) => getComputedStyle(summary, "::after").transform),
+ )
+ .not.toBe(geometry.chevronClosedTransform);
+ },
+ );
+ });
+
+ it("preserves the shared Markdown gutter in RTL transcripts", async () => {
+ await suite.withPage(
+ {
+ colorScheme: "light",
+ locale: "ar",
+ serviceWorkers: "block",
+ viewport: { height: 800, width: 1180 },
+ },
+ async ({ page }) => {
+ await installMockGateway(page, {
+ historyMessages: [
+ {
+ content: [
+ {
+ type: "text",
+ text: [
+ "## فحص المحاذاة",
+ "",
+ "- عنصر نقطي",
+ "",
+ "1. عنصر مرقم",
+ "",
+ "- [ ] مهمة غير مكتملة",
+ "",
+ "",
+ "تفاصيل إضافية
",
+ "",
+ "محتوى التفاصيل",
+ " ",
+ ].join("\n"),
+ },
+ ],
+ role: "assistant",
+ timestamp: Date.now(),
+ },
+ ],
+ });
+
+ await page.goto(`${suite.server.baseUrl}chat`);
+ const markdown = page.locator(".chat-group.assistant .chat-text[dir='rtl']", {
+ hasText: "فحص المحاذاة",
+ });
+ await markdown.waitFor();
+
+ const geometry = await markdown.evaluate((root) => {
+ const textRight = (selector: string) => {
+ const element = root.querySelector(selector);
+ if (!element) {
+ throw new Error(`Missing element for ${selector}`);
+ }
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
+ let text = walker.nextNode();
+ while (text && !text.textContent?.trim()) {
+ text = walker.nextNode();
+ }
+ if (!text) {
+ throw new Error(`Missing text for ${selector}`);
+ }
+ const range = document.createRange();
+ range.selectNodeContents(text);
+ return range.getBoundingClientRect().right;
+ };
+ const checkbox = root.querySelector(".task-list-item-checkbox");
+ const task = root.querySelector(".task-list-item");
+ const unorderedList = root.querySelector("ul:not(.contains-task-list)");
+ const orderedList = root.querySelector("ol");
+ const summary = root.querySelector("details:not(.json-collapse) > summary");
+ if (!checkbox || !task || !unorderedList || !orderedList || !summary) {
+ throw new Error("Missing RTL Markdown geometry");
+ }
+ const checkboxRect = checkbox.getBoundingClientRect();
+ return {
+ checkboxGap: checkboxRect.left - textRight(".task-list-item"),
+ chevronInlineEnd: getComputedStyle(summary, "::after").insetInlineEnd,
+ orderedPaddingInlineStart: getComputedStyle(orderedList).paddingInlineStart,
+ textStarts: [
+ textRight("ul:not(.contains-task-list) > li"),
+ textRight("ol > li"),
+ textRight(".task-list-item"),
+ textRight("details:not(.json-collapse) > summary"),
+ ],
+ unorderedPaddingInlineStart: getComputedStyle(unorderedList).paddingInlineStart,
+ };
+ });
+
+ expect(
+ Math.max(...geometry.textStarts) - Math.min(...geometry.textStarts),
+ ).toBeLessThanOrEqual(1);
+ expect(Number.parseFloat(geometry.unorderedPaddingInlineStart)).toBe(32);
+ expect(Number.parseFloat(geometry.orderedPaddingInlineStart)).toBe(32);
+ expect(geometry.checkboxGap).toBeGreaterThanOrEqual(7);
+ expect(geometry.checkboxGap).toBeLessThanOrEqual(9);
+ expect(Number.parseFloat(geometry.chevronInlineEnd)).toBe(0);
+ },
+ );
+ });
+});
diff --git a/ui/src/styles/chat/text.css b/ui/src/styles/chat/text.css
index a98f5bde87a2..662cf0486f0c 100644
--- a/ui/src/styles/chat/text.css
+++ b/ui/src/styles/chat/text.css
@@ -137,24 +137,103 @@
font-weight: 500;
}
-.chat-text :where(ul, ol) {
- padding-left: 1.2em;
+:is(.chat-text, .chat-thinking) {
+ --chat-markdown-indent: var(--space-7);
+ --chat-markdown-marker-gap: var(--space-2);
+}
+
+:is(.chat-text, .chat-thinking) :where(ul, ol) {
+ padding-inline-start: var(--chat-markdown-indent);
+}
+
+:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse)) {
+ margin-inline-start: var(--space-3);
+ padding-inline-start: calc(var(--chat-markdown-indent) - var(--space-3));
}
.chat-text :where(li + li) {
margin-top: 0.4em;
}
-/* Hide default marker only for unordered task lists; ordered lists keep numbers */
-.chat-text :where(ul > .task-list-item),
-.chat-thinking :where(ul > .task-list-item) {
+/* Keep bullets, checkboxes, and numbers in one marker column while their text
+ shares the same start edge. */
+:is(.chat-text, .chat-thinking) :where(ul > li:not(.task-list-item))::marker {
+ content: "";
+}
+
+:is(.chat-text, .chat-thinking) :where(ul > .task-list-item) {
list-style: none;
}
-.chat-text :where(.task-list-item-checkbox),
-.chat-thinking :where(.task-list-item-checkbox) {
- margin-right: 0.4em;
- vertical-align: middle;
+:is(.chat-text, .chat-thinking) :where(ol > li)::marker {
+ font-variant-numeric: tabular-nums;
+}
+
+:is(.chat-text, .chat-thinking) :where(ul > li) {
+ position: relative;
+}
+
+:is(.chat-text, .chat-thinking) :where(ul > li:not(.task-list-item))::before {
+ position: absolute;
+ inset-inline-end: calc(100% + var(--chat-markdown-marker-gap));
+ content: "•";
+ inset-block-start: 0;
+ inline-size: calc(var(--chat-markdown-indent) - var(--chat-markdown-marker-gap));
+ text-align: end;
+}
+
+/* Authored disclosures align with list text but place their affordance at the
+ row edge; JSON code blocks retain their separate compact presentation. */
+:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse) > summary) {
+ position: relative;
+ padding-inline-end: var(--space-7);
+ list-style: none;
+ cursor: var(--cursor-action);
+}
+
+:is(.chat-text, .chat-thinking)
+ :where(details:not(.json-collapse) > summary)::-webkit-details-marker {
+ display: none;
+}
+
+:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse) > summary)::after {
+ position: absolute;
+ content: "";
+ inset-block-start: calc((1lh - var(--space-4)) / 2);
+ inset-inline-end: 0;
+ inline-size: var(--space-4);
+ block-size: var(--space-4);
+ background: var(--select-chevron) center / var(--space-4) no-repeat;
+ transform: rotate(-90deg);
+ transition: transform var(--duration-normal) var(--ease-out);
+}
+
+:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse)[open] > summary)::after {
+ transform: rotate(0deg);
+}
+
+:is(.chat-text, .chat-thinking) :where(.task-list-item-checkbox) {
+ position: absolute;
+ inset-block-start: calc((1lh - var(--space-4)) / 2);
+ inset-inline-end: calc(100% + var(--chat-markdown-marker-gap));
+ inline-size: var(--space-4);
+ block-size: var(--space-4);
+ margin: 0;
+}
+
+:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse)[open]) {
+ padding-inline-start: calc(var(--chat-markdown-indent) - var(--space-3) - 1px);
+ border-inline-start: 1px solid var(--border);
+}
+
+:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse)[open] > summary) {
+ margin-bottom: var(--space-2);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ :is(.chat-text, .chat-thinking) :where(details:not(.json-collapse) > summary)::after {
+ transition: none;
+ }
}
.chat-text :where(a) {
@@ -428,11 +507,6 @@
text-align: right;
}
-.chat-text[dir="rtl"] :where(ul, ol) {
- padding-left: 0;
- padding-right: 1.5em;
-}
-
.chat-text[dir="rtl"] :where(blockquote) {
border-left: none;
border-right: 3px solid var(--border);