feat(ui): render chat notice rows as markdown (#113450)

System-notice rows (local slash-command output such as /help) now render
through the shared sanitized markdown pipeline (code-block chrome off,
DOMPurify allowlist, breaks preserved) instead of literal pre-wrap text,
so authored bold/inline-code formatting displays properly. Block content
(lists, pre, blockquote) centers as a block but reads left-aligned.
Follow-up to #112938.
This commit is contained in:
Peter Steinberger
2026-07-24 18:50:07 -07:00
committed by GitHub
parent ab04b2103e
commit cd76809d9a
3 changed files with 35 additions and 5 deletions
+6 -1
View File
@@ -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<ChatItem, { kind: "divider" }>,
@@ -51,7 +54,9 @@ export function renderChatDivider(
export function renderChatNotice(item: Extract<ChatItem, { kind: "notice" }>) {
return html`
<div class="chat-notice" data-chat-row-key=${item.key} data-ts=${String(item.timestamp)}>
${item.text}
<div class="chat-text" dir=${detectTextDirection(item.text)}>
${unsafeHTML(toSanitizedMarkdownHtml(item.text, { codeBlockChrome: "none" }))}
</div>
</div>
`;
}
@@ -16,6 +16,7 @@ import {
} from "./chat-message.ts";
const localStorageValues = new Map<string, string>();
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<img src=x onerror=alert(1)><script>alert(1)</script>",
timestamp: 1000,
}),
container,
);
const notice = container.querySelector<HTMLElement>(".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", () => {
+15 -1
View File
@@ -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 */