From 2a3244b7838adbd9ac2fbc85e2c8a292b1744291 Mon Sep 17 00:00:00 2001 From: Johnathan Mo <2023johnathanmo@gmail.com> Date: Sun, 29 Mar 2026 03:25:32 -0400 Subject: [PATCH] fix: render markdown in chat messages instead of raw text Add lightweight markdown renderer for chat message display. Handles **bold**, *italic*, numbered/bullet lists, and paragraph breaks. Apply to both ChatView (dark theme) and ChatScreen (wizard) components. Also gate VncPip rendering on stream URL presence to avoid wasting space in the message area. Closes TAB-62. Co-Authored-By: Claude Opus 4.6 --- src/components/chat/ChatView.tsx | 9 +-- src/components/wizard/ChatScreen.tsx | 9 ++- src/lib/renderMarkdown.tsx | 97 ++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 src/lib/renderMarkdown.tsx diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index dc11fa8..48eb375 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from "react"; import { AnimatePresence, motion } from "framer-motion"; +import { renderMarkdown } from "@/lib/renderMarkdown"; // ─── Types ────────────────────────────────────── @@ -49,8 +50,8 @@ function TwinMessage({ content, timestamp }: { content: string; timestamp: strin transition={{ duration: 0.3, ease: "easeOut" }} className="flex flex-col gap-1 max-w-[85%]" > -
-

{content}

+
+ {renderMarkdown(content)}
{timestamp} @@ -267,8 +268,8 @@ export default function ChatView({ {isThinking && } - {/* VNC PiP */} - + {/* VNC PiP — only show when stream is active */} + {vncStreamUrl && }
{/* Input */} diff --git a/src/components/wizard/ChatScreen.tsx b/src/components/wizard/ChatScreen.tsx index 8378cb0..5572886 100644 --- a/src/components/wizard/ChatScreen.tsx +++ b/src/components/wizard/ChatScreen.tsx @@ -4,6 +4,7 @@ import { useState, useRef, useEffect } from "react"; import { postChat } from "@/lib/api"; import type { ActionTaken } from "@/lib/api"; import MascotFace from "@/components/mascot/MascotFace"; +import { renderMarkdown } from "@/lib/renderMarkdown"; interface ChatMessage { role: "user" | "assistant"; @@ -93,7 +94,13 @@ export default function ChatScreen({ sessionId, name }: ChatScreenProps) { : "bg-gray-100 text-black" }`} > -

{msg.text}

+ {msg.role === "assistant" ? ( +
+ {renderMarkdown(msg.text)} +
+ ) : ( +

{msg.text}

+ )} {msg.actions && msg.actions.length > 0 && (
{msg.actions.map((action, j) => ( diff --git a/src/lib/renderMarkdown.tsx b/src/lib/renderMarkdown.tsx new file mode 100644 index 0000000..46eb21b --- /dev/null +++ b/src/lib/renderMarkdown.tsx @@ -0,0 +1,97 @@ +import React from "react"; + +/** + * Lightweight markdown renderer for chat messages. + * Handles: **bold**, *italic*, numbered lists, and paragraph breaks. + */ + +function renderInline(text: string): React.ReactNode[] { + const parts: React.ReactNode[] = []; + // Match **bold** and *italic* patterns + const regex = /(\*\*(.+?)\*\*|\*(.+?)\*)/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = regex.exec(text)) !== null) { + // Text before this match + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + + if (match[2]) { + // **bold** + parts.push({match[2]}); + } else if (match[3]) { + // *italic* + parts.push({match[3]}); + } + + lastIndex = match.index + match[0].length; + } + + // Remaining text after last match + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts.length > 0 ? parts : [text]; +} + +export function renderMarkdown(text: string): React.ReactNode { + // Split into paragraphs by double newlines + const paragraphs = text.split(/\n{2,}/); + + return paragraphs.map((paragraph, pIdx) => { + const trimmed = paragraph.trim(); + if (!trimmed) return null; + + // Check if this paragraph is a numbered list block + const lines = trimmed.split("\n"); + const isNumberedList = lines.every( + (line) => /^\d+\.\s/.test(line.trim()) || line.trim() === "" + ); + + if (isNumberedList) { + return ( +
    + {lines + .filter((line) => line.trim()) + .map((line, lIdx) => { + const content = line.replace(/^\d+\.\s*/, ""); + return
  1. {renderInline(content)}
  2. ; + })} +
+ ); + } + + // Check if it's a bullet list + const isBulletList = lines.every( + (line) => /^[-•]\s/.test(line.trim()) || line.trim() === "" + ); + + if (isBulletList) { + return ( + + ); + } + + // Regular paragraph — preserve single newlines as
+ return ( +

+ {lines.map((line, lIdx) => ( + + {lIdx > 0 &&
} + {renderInline(line)} +
+ ))} +

+ ); + }); +}