diff --git a/packages/web/src/components/chat/ChatPage.tsx b/packages/web/src/components/chat/ChatPage.tsx
index 2b5ae78..8722c43 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 { MarkdownRenderer } from "@/components/shared/MarkdownRenderer";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/input";
import { SectionHeading } from "@/components/ui/typography";
@@ -146,7 +147,9 @@ 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..848f3b4 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 { MarkdownRenderer } from "@/components/shared/MarkdownRenderer";
import { Pagination } from "@/components/shared/Pagination";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -263,7 +264,9 @@ export function SessionDetail() {
{new Date(msg.created_at).toLocaleString()}
)}
- {mask(msg.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..3bb96f7
--- /dev/null
+++ b/packages/web/src/test/message-markdown.test.tsx
@@ -0,0 +1,175 @@
+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();
+ });
+});
+
+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();
+ });
+});