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);