diff --git a/ui/src/pages/chat/components/chat-divider.ts b/ui/src/pages/chat/components/chat-divider.ts index ced2e2a4b52c..cce5375a8c1e 100644 --- a/ui/src/pages/chat/components/chat-divider.ts +++ b/ui/src/pages/chat/components/chat-divider.ts @@ -1,5 +1,8 @@ import { html, nothing } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { toSanitizedMarkdownHtml } from "../../../components/markdown.ts"; import type { ChatItem } from "../../../lib/chat/chat-types.ts"; +import { detectTextDirection } from "../../../lib/text-direction.ts"; export function renderChatDivider( item: Extract, @@ -51,7 +54,9 @@ export function renderChatDivider( export function renderChatNotice(item: Extract) { return html`
- ${item.text} +
+ ${unsafeHTML(toSanitizedMarkdownHtml(item.text, { codeBlockChrome: "none" }))} +
`; } diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index 258caeb4afb5..67d62263d247 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -16,6 +16,7 @@ import { } from "./chat-message.ts"; const localStorageValues = new Map(); +const renderMarkdownHtml = markdown.toSanitizedMarkdownHtml; const markdownRenderMock = vi.fn( (value: string, _options?: { codeBlockChrome?: "copy" | "none"; fileLinks?: boolean }) => value, ); @@ -1903,21 +1904,31 @@ describe("grouped chat rendering", () => { expect(attribution?.nextElementSibling?.classList.contains("chat-bubble")).toBe(true); }); - it("renders multiline system notices as plain centered rows", () => { + it("renders multiline system notices as sanitized markdown", () => { const container = document.createElement("div"); + markdownRenderMock.mockImplementationOnce(renderMarkdownHtml); render( renderChatNotice({ kind: "notice", key: "notice:command", - text: "first line\n second line", + text: "**first line**\nsecond line\n", timestamp: 1000, }), container, ); const notice = container.querySelector(".chat-notice"); - expect(notice?.textContent?.trim()).toBe("first line\n second line"); + expect(notice?.querySelector("strong")?.textContent).toBe("first line"); + expect(notice?.textContent).not.toContain("**"); + expect(notice?.querySelector("br")).not.toBeNull(); + expect(notice?.textContent).toContain("first line"); + expect(notice?.textContent).toContain("second line"); + expect(notice?.querySelector("script")).toBeNull(); + expect(notice?.querySelector("img[onerror]")).toBeNull(); expect(notice?.dataset.chatRowKey).toBe("notice:command"); + expect(markdownRenderMock).toHaveBeenCalledWith(expect.any(String), { + codeBlockChrome: "none", + }); }); it("uses the current profile display name for the signed-in user's historical messages", () => { diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index 1401641f2b32..40fc72273be6 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -411,7 +411,21 @@ font-size: 12px; line-height: 1.4; text-align: center; - white-space: pre-wrap; +} + +.chat-notice .chat-text { + color: inherit; + font-size: inherit; + line-height: inherit; +} + +.chat-notice :where(ul, ol, pre, blockquote) { + display: inline-block; + text-align: left; +} + +.chat-notice :where(:not(pre) > code) { + color: inherit; } /* Avatar Styles */