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 - {renderInline(content)}
;
+ })}
+
+ );
+ }
+
+ // Check if it's a bullet list
+ const isBulletList = lines.every(
+ (line) => /^[-•]\s/.test(line.trim()) || line.trim() === ""
+ );
+
+ if (isBulletList) {
+ return (
+
+ {lines
+ .filter((line) => line.trim())
+ .map((line, lIdx) => {
+ const content = line.replace(/^[-•]\s*/, "");
+ return - {renderInline(content)}
;
+ })}
+
+ );
+ }
+
+ // Regular paragraph — preserve single newlines as
+ return (
+
+ {lines.map((line, lIdx) => (
+
+ {lIdx > 0 &&
}
+ {renderInline(line)}
+
+ ))}
+
+ );
+ });
+}