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)} +
+ ))} +

+ ); + }); +}