diff --git a/packages/web/package.json b/packages/web/package.json
index 946ee2a..7141107 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -39,6 +39,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"react-markdown": "^10.1.0",
+ "remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
diff --git a/packages/web/src/components/chat/ChatPage.tsx b/packages/web/src/components/chat/ChatPage.tsx
index 2b5ae78..a0b3a1e 100644
--- a/packages/web/src/components/chat/ChatPage.tsx
+++ b/packages/web/src/components/chat/ChatPage.tsx
@@ -4,6 +4,7 @@ import { Brain, Send } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useChat } from "@/api/queries";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
+import { MessageMarkdown } from "@/components/shared/MessageMarkdown";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/input";
import { SectionHeading } from "@/components/ui/typography";
@@ -146,7 +147,7 @@ export function ChatPage() {
}
}
>
-
{mask(msg.content)}
+
))}
diff --git a/packages/web/src/components/sessions/SessionDetail.tsx b/packages/web/src/components/sessions/SessionDetail.tsx
index ed453af..6c22ebf 100644
--- a/packages/web/src/components/sessions/SessionDetail.tsx
+++ b/packages/web/src/components/sessions/SessionDetail.tsx
@@ -20,6 +20,7 @@ import { Badge } from "@/components/shared/Badge";
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
import { JsonViewer } from "@/components/shared/JsonViewer";
import { PageLoader } from "@/components/shared/LoadingSpinner";
+import { MessageMarkdown } from "@/components/shared/MessageMarkdown";
import { Pagination } from "@/components/shared/Pagination";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -263,7 +264,7 @@ export function SessionDetail() {
{new Date(msg.created_at).toLocaleString()}
)}
- {mask(msg.content)}
+
))}
diff --git a/packages/web/src/components/shared/MessageMarkdown.tsx b/packages/web/src/components/shared/MessageMarkdown.tsx
new file mode 100644
index 0000000..2d9b846
--- /dev/null
+++ b/packages/web/src/components/shared/MessageMarkdown.tsx
@@ -0,0 +1,29 @@
+import ReactMarkdown, { type Components } from "react-markdown";
+import remarkBreaks from "remark-breaks";
+import remarkGfm from "remark-gfm";
+
+const REMARK_PLUGINS = [remarkGfm, remarkBreaks];
+
+const COMPONENTS: Components = {
+ h1: ({ children }) => {children}
,
+ h2: ({ children }) => {children}
,
+ h3: ({ children }) => {children}
,
+ p: ({ children }) => {children}
,
+ ul: ({ children }) => ,
+ ol: ({ children }) => {children}
,
+ pre: ({ children }) => {children},
+};
+
+interface MessageMarkdownProps {
+ content: string;
+}
+
+export function MessageMarkdown({ content }: MessageMarkdownProps) {
+ return (
+
+
+ {content}
+
+
+ );
+}
diff --git a/packages/web/src/test/message-markdown.test.tsx b/packages/web/src/test/message-markdown.test.tsx
new file mode 100644
index 0000000..7a37cd0
--- /dev/null
+++ b/packages/web/src/test/message-markdown.test.tsx
@@ -0,0 +1,249 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createMemoryHistory, createRouter, RouterProvider } from "@tanstack/react-router";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { DemoProvider } from "@/context/DemoContext";
+import { MetadataProvider } from "@/context/MetadataContext";
+import { saveStore } from "@/lib/config";
+import { routeTree } from "@/routeTree.gen";
+
+const { httpFetch } = vi.hoisted(() => ({ httpFetch: vi.fn() }));
+vi.mock("@/lib/http", () => ({ httpFetch }));
+
+const WORKSPACE_ID = "ws-alpha";
+const SESSION_ID = "sess-1";
+const PEER_ID = "peer-bot";
+const INSTANCE = {
+ id: "inst-1",
+ name: "Local",
+ baseUrl: "http://localhost:8000",
+ token: "",
+};
+
+function json(body: unknown, status = 200) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+function requestOf(input: Request | string, init?: RequestInit) {
+ return typeof input === "string" ? new Request(input, init) : input;
+}
+
+function message(content: string) {
+ return {
+ id: "msg-1",
+ content,
+ peer_id: PEER_ID,
+ session_id: SESSION_ID,
+ workspace_id: WORKSPACE_ID,
+ created_at: "2026-01-01T00:00:00Z",
+ token_count: 12,
+ };
+}
+
+function mockHoncho(options: { messageContent?: string; chatContent?: string } = {}) {
+ httpFetch.mockImplementation(async (input: Request | string, init?: RequestInit) => {
+ const req = requestOf(input, init);
+ const url = req.url;
+ if (url.includes("/messages/list")) {
+ return json({
+ items: [message(options.messageContent ?? "hello")],
+ total: 1,
+ page: 1,
+ size: 50,
+ pages: 1,
+ });
+ }
+ if (url.includes("/chat")) {
+ return json({ content: options.chatContent ?? "ok" });
+ }
+ return json({ items: [], total: 0, page: 1, size: 20, pages: 0 });
+ });
+}
+
+function renderAt(path: string) {
+ saveStore({ instances: [INSTANCE], activeId: INSTANCE.id });
+ const router = createRouter({
+ routeTree,
+ history: createMemoryHistory({ initialEntries: [path] }),
+ });
+ const qc = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return render(
+
+
+
+ {/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
+
+
+
+ ,
+ );
+}
+
+function sessionPath() {
+ return `/workspaces/${WORKSPACE_ID}/sessions/${SESSION_ID}`;
+}
+
+function chatPath() {
+ return `/workspaces/${WORKSPACE_ID}/peers/${PEER_ID}/chat`;
+}
+
+describe("session message markdown", () => {
+ afterEach(() => {
+ httpFetch.mockReset();
+ localStorage.clear();
+ });
+
+ it("renders a heading instead of raw markdown syntax", async () => {
+ mockHoncho({ messageContent: "# Status update" });
+ renderAt(sessionPath());
+ expect(await screen.findByRole("heading", { name: "Status update" })).toBeInTheDocument();
+ });
+
+ it("renders a list item from markdown bullets", async () => {
+ mockHoncho({ messageContent: "- first item\n- second item" });
+ renderAt(sessionPath());
+ expect(await screen.findByText("first item")).toBeInTheDocument();
+ });
+
+ it("renders fenced code as a code block", async () => {
+ mockHoncho({ messageContent: "```\nconst x = 1\n```" });
+ renderAt(sessionPath());
+ expect(await screen.findByText("const x = 1")).toBeInTheDocument();
+ });
+
+ it("renders a GFM table header", async () => {
+ mockHoncho({ messageContent: "| Name | Value |\n| --- | --- |\n| alpha | 1 |" });
+ renderAt(sessionPath());
+ expect(await screen.findByRole("columnheader", { name: "Name" })).toBeInTheDocument();
+ });
+
+ it("keeps a plain-text message readable", async () => {
+ mockHoncho({ messageContent: "just a normal sentence" });
+ renderAt(sessionPath());
+ expect(await screen.findByText("just a normal sentence")).toBeInTheDocument();
+ });
+
+ it("does not execute raw HTML in message content", async () => {
+ mockHoncho({ messageContent: '
' });
+ renderAt(sessionPath());
+ await screen.findByText(/script/i);
+ expect(document.querySelector("script")).toBeNull();
+ });
+
+ it("does not turn a javascript URL into an executable link", async () => {
+ mockHoncho({ messageContent: "[click me](javascript:alert(1))" });
+ renderAt(sessionPath());
+ await screen.findByText("click me");
+ expect(document.querySelector('a[href^="javascript:"]')).toBeNull();
+ });
+
+ it("masks private message text in demo mode before rendering", async () => {
+ localStorage.setItem("openconcho:demo", "true");
+ mockHoncho({ messageContent: "secret-token-value" });
+ renderAt(sessionPath());
+ await screen.findByText("Session detail");
+ expect(screen.queryByText("secret-token-value")).not.toBeInTheDocument();
+ });
+
+ it("still shows message token count beside the rendered body", async () => {
+ mockHoncho({ messageContent: "**bold** note" });
+ renderAt(sessionPath());
+ expect(await screen.findByText("12 tokens")).toBeInTheDocument();
+ });
+
+ it("keeps a single newline in a plain-text message", async () => {
+ mockHoncho({ messageContent: "line one\nline two" });
+ renderAt(sessionPath());
+ const line = await screen.findByText(/line one/);
+ expect(line.querySelector("br")).not.toBeNull();
+ });
+
+ it("keeps timestamp lines in source order", async () => {
+ mockHoncho({
+ messageContent: "[2026-01-01 00:00:00] alpha-ts\n[2026-01-02 00:00:00] beta-ts",
+ });
+ renderAt(sessionPath());
+ await screen.findByText(/alpha-ts/);
+ const text = document.body.textContent ?? "";
+ expect(text.indexOf("alpha-ts")).toBeLessThan(text.indexOf("beta-ts"));
+ });
+
+ it("keeps a nested list nested", async () => {
+ mockHoncho({ messageContent: "- outer-md\n - nested-md" });
+ renderAt(sessionPath());
+ const nested = await screen.findByText("nested-md");
+ expect(nested.closest("li")?.parentElement?.closest("li")).not.toBeNull();
+ });
+
+ it("keeps an indented code block as a code block", async () => {
+ mockHoncho({ messageContent: "Intro line.\n\n const indented = 1\n" });
+ renderAt(sessionPath());
+ expect((await screen.findByText("const indented = 1")).closest("pre")).not.toBeNull();
+ });
+
+ it("leaves a bracketed token as literal text", async () => {
+ mockHoncho({
+ messageContent: "## Contradictions\n\n**CONTRADICTION**: See [abcdefghijklmn] here",
+ });
+ renderAt(sessionPath());
+ expect(await screen.findByText(/abcdefghijklmn/)).toBeInTheDocument();
+ });
+
+ it("renders representation-like headings as ordinary headings", async () => {
+ mockHoncho({
+ messageContent: "## Inductive Observations\n\n**Pattern** [high]: user likes cats",
+ });
+ renderAt(sessionPath());
+ await screen.findByText(/user likes cats/);
+ expect(screen.queryByText("high")).toBeNull();
+ });
+});
+
+describe("chat message markdown", () => {
+ afterEach(() => {
+ httpFetch.mockReset();
+ localStorage.clear();
+ });
+
+ it("renders assistant markdown as a heading in chat", async () => {
+ const user = userEvent.setup();
+ Element.prototype.scrollIntoView = vi.fn();
+ mockHoncho({ chatContent: "# Assistant heading" });
+ renderAt(chatPath());
+ await user.type(await screen.findByPlaceholderText(/Message this peer/i), "hello{Enter}");
+ expect(await screen.findByRole("heading", { name: "Assistant heading" })).toBeInTheDocument();
+ });
+
+ it("does not override user bubble paragraph color", async () => {
+ const user = userEvent.setup();
+ Element.prototype.scrollIntoView = vi.fn();
+ mockHoncho({ chatContent: "ok" });
+ renderAt(chatPath());
+ await user.type(
+ await screen.findByPlaceholderText(/Message this peer/i),
+ "Contrast check{Enter}",
+ );
+ const text = await screen.findByText("Contrast check");
+ const paragraph = text.closest("p") ?? text;
+ expect(paragraph.getAttribute("style") ?? "").not.toMatch(/--text-2/);
+ });
+
+ it("does not override user bubble list color", async () => {
+ const user = userEvent.setup();
+ Element.prototype.scrollIntoView = vi.fn();
+ mockHoncho({ chatContent: "ok" });
+ renderAt(chatPath());
+ await user.type(
+ await screen.findByPlaceholderText(/Message this peer/i),
+ "- bubble-item{Enter}",
+ );
+ const item = await screen.findByText("bubble-item");
+ expect(item.closest("ul")?.getAttribute("style") ?? "").not.toMatch(/--text-2/);
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2060c0a..57d0ed3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -188,6 +188,9 @@ importers:
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.5)
+ remark-breaks:
+ specifier: ^4.0.0
+ version: 4.0.0
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
@@ -2904,6 +2907,9 @@ packages:
mdast-util-mdxjs-esm@2.0.1:
resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
+ mdast-util-newline-to-break@2.0.0:
+ resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==}
+
mdast-util-phrasing@4.1.0:
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
@@ -3445,6 +3451,9 @@ packages:
resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==}
engines: {node: '>=14'}
+ remark-breaks@4.0.0:
+ resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==}
+
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@@ -6722,6 +6731,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ mdast-util-newline-to-break@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-find-and-replace: 3.0.2
+
mdast-util-phrasing@4.1.0:
dependencies:
'@types/mdast': 4.0.4
@@ -7310,6 +7324,12 @@ snapshots:
dependencies:
'@pnpm/npm-conf': 3.0.2
+ remark-breaks@4.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-newline-to-break: 2.0.0
+ unified: 11.0.5
+
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4