Skip to content

Commit 1588800

Browse files
committed
feat: add MarkdownRenderer component with syntax highlighting and copy functionality
1 parent 54daa4b commit 1588800

5 files changed

Lines changed: 1247 additions & 35 deletions

File tree

apps/web/components/chat/ChatMessage.tsx

Lines changed: 82 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,17 @@ import { cn } from "@/lib/utils";
44
import type { ChatMessage, ContextItem } from "@/types/chat";
55
import { AGENTS } from "@/types/chat";
66
import { useState } from "react";
7-
import { ChevronDown, ChevronUp, Code, User, Bot } from "lucide-react";
7+
import {
8+
ChevronDown,
9+
ChevronUp,
10+
Code,
11+
User,
12+
FileCode,
13+
Box,
14+
GitCommit,
15+
File,
16+
} from "lucide-react";
17+
import { MarkdownRenderer } from "./MarkdownRenderer";
818

919
interface ChatMessageProps {
1020
message: ChatMessage;
@@ -17,6 +27,12 @@ export function ChatMessageItem({ message }: ChatMessageProps) {
1727
? AGENTS.find((a) => a.type === message.agentType)
1828
: null;
1929

30+
// Filter out empty context items
31+
const validContext = message.context?.filter(
32+
(item) =>
33+
item.name && item.name !== "N/A" && item.content && item.content.trim()
34+
);
35+
2036
return (
2137
<div
2238
className={cn(
@@ -37,7 +53,7 @@ export function ChatMessageItem({ message }: ChatMessageProps) {
3753
) : agent ? (
3854
<agent.Icon className="w-4 h-4" />
3955
) : (
40-
<Bot className="w-4 h-4" />
56+
<Code className="w-4 h-4" />
4157
)}
4258
</div>
4359

@@ -56,34 +72,42 @@ export function ChatMessageItem({ message }: ChatMessageProps) {
5672
</span>
5773
</div>
5874

59-
<div className="prose prose-sm dark:prose-invert max-w-none">
60-
<p className="whitespace-pre-wrap wrap-break-word">
75+
{isUser ? (
76+
<p className="whitespace-pre-wrap overflow-wrap-anywhere">
6177
{message.content}
78+
</p>
79+
) : (
80+
<div className="relative">
81+
<MarkdownRenderer content={message.content} />
6282
{message.isStreaming && (
6383
<span className="inline-block w-2 h-4 ml-1 bg-primary animate-pulse" />
6484
)}
65-
</p>
66-
</div>
85+
</div>
86+
)}
6787

68-
{message.context && message.context.length > 0 && (
69-
<div className="mt-3">
88+
{validContext && validContext.length > 0 && (
89+
<div className="mt-4 pt-3 border-t border-border/50">
7090
<button
7191
onClick={() => setShowContext(!showContext)}
72-
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
92+
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
7393
>
74-
<Code className="w-3 h-3" />
75-
{message.context.length} context item
76-
{message.context.length > 1 ? "s" : ""}
77-
{showContext ? (
78-
<ChevronUp className="w-3 h-3" />
79-
) : (
80-
<ChevronDown className="w-3 h-3" />
81-
)}
94+
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/50 hover:bg-muted transition-colors">
95+
<FileCode className="w-3.5 h-3.5" />
96+
<span className="font-medium">
97+
{validContext.length} source
98+
{validContext.length > 1 ? "s" : ""}
99+
</span>
100+
{showContext ? (
101+
<ChevronUp className="w-3.5 h-3.5" />
102+
) : (
103+
<ChevronDown className="w-3.5 h-3.5" />
104+
)}
105+
</div>
82106
</button>
83107

84108
{showContext && (
85-
<div className="mt-2 space-y-2">
86-
{message.context.map((item, index) => (
109+
<div className="mt-3 space-y-2">
110+
{validContext.map((item, index) => (
87111
<ContextItemCard key={index} item={item} />
88112
))}
89113
</div>
@@ -99,36 +123,59 @@ interface ContextItemCardProps {
99123
item: ContextItem;
100124
}
101125

126+
function getContextIcon(type: string) {
127+
switch (type?.toLowerCase()) {
128+
case "function":
129+
return <Code className="w-3 h-3" />;
130+
case "class":
131+
return <Box className="w-3 h-3" />;
132+
case "commit":
133+
return <GitCommit className="w-3 h-3" />;
134+
case "file":
135+
return <File className="w-3 h-3" />;
136+
default:
137+
return <FileCode className="w-3 h-3" />;
138+
}
139+
}
140+
102141
function ContextItemCard({ item }: ContextItemCardProps) {
103142
const [expanded, setExpanded] = useState(false);
104-
const maxPreviewLength = 200;
105-
const needsExpansion = item.content.length > maxPreviewLength;
143+
const maxPreviewLength = 300;
144+
const content = item.content || "";
145+
const needsExpansion = content.length > maxPreviewLength;
106146

107147
return (
108-
<div className="border rounded-md overflow-hidden bg-muted/30">
109-
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b">
110-
<div className="flex items-center gap-2">
111-
<span className="text-xs font-medium px-2 py-0.5 rounded bg-primary/10 text-primary">
112-
{item.type}
148+
<div className="group rounded-lg border border-border/60 bg-muted/30 overflow-hidden hover:border-border transition-colors">
149+
<div className="flex items-center justify-between px-3 py-2 bg-muted/50">
150+
<div className="flex items-center gap-2 min-w-0">
151+
<div className="flex items-center gap-1.5 shrink-0 text-muted-foreground">
152+
{getContextIcon(item.type)}
153+
<span className="text-xs font-medium uppercase tracking-wide">
154+
{item.type || "Code"}
155+
</span>
156+
</div>
157+
<span className="text-xs text-muted-foreground/60"></span>
158+
<span className="text-xs font-mono text-foreground/80 truncate">
159+
{item.name}
113160
</span>
114-
<span className="text-sm font-mono">{item.name}</span>
115161
</div>
116-
{item.score !== undefined && (
117-
<span className="text-xs text-muted-foreground">
118-
Score: {(item.score * 100).toFixed(1)}%
162+
{item.score !== undefined && item.score > 0 && (
163+
<span className="text-[10px] font-medium text-muted-foreground bg-background/50 px-1.5 py-0.5 rounded shrink-0 ml-2">
164+
{(item.score * 100).toFixed(0)}%
119165
</span>
120166
)}
121167
</div>
122-
<div className="p-3">
123-
<pre className="text-xs overflow-x-auto whitespace-pre-wrap font-mono">
168+
169+
<div className="px-3 py-2 bg-background/50">
170+
<pre className="text-xs overflow-x-auto whitespace-pre-wrap font-mono text-foreground/70 leading-relaxed">
124171
{expanded || !needsExpansion
125-
? item.content
126-
: `${item.content.slice(0, maxPreviewLength)}...`}
172+
? content
173+
: `${content.slice(0, maxPreviewLength)}...`}
127174
</pre>
128175
{needsExpansion && (
129176
<button
130177
onClick={() => setExpanded(!expanded)}
131-
className="text-xs text-primary hover:underline mt-2"
178+
className="text-xs text-primary/80 hover:text-primary hover:underline mt-2 font-medium"
132179
>
133180
{expanded ? "Show less" : "Show more"}
134181
</button>
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"use client";
2+
3+
import ReactMarkdown from "react-markdown";
4+
import remarkGfm from "remark-gfm";
5+
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
6+
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
7+
import { cn } from "@/lib/utils";
8+
import { Copy, Check } from "lucide-react";
9+
import { useState } from "react";
10+
11+
interface MarkdownRendererProps {
12+
content: string;
13+
className?: string;
14+
}
15+
16+
export function MarkdownRenderer({
17+
content,
18+
className,
19+
}: MarkdownRendererProps) {
20+
return (
21+
<div
22+
className={cn("prose prose-sm dark:prose-invert max-w-none", className)}
23+
>
24+
<ReactMarkdown
25+
remarkPlugins={[remarkGfm]}
26+
components={{
27+
// Code blocks with syntax highlighting
28+
code({ className, children, ...props }) {
29+
const match = /language-(\w+)/.exec(className || "");
30+
const isInline = !match;
31+
32+
if (isInline) {
33+
return (
34+
<code
35+
className="px-1.5 py-0.5 rounded bg-muted font-mono text-sm"
36+
{...props}
37+
>
38+
{children}
39+
</code>
40+
);
41+
}
42+
43+
return (
44+
<CodeBlock language={match[1]}>
45+
{String(children).replace(/\n$/, "")}
46+
</CodeBlock>
47+
);
48+
},
49+
// Style other elements
50+
p({ children }) {
51+
return <p className="mb-3 last:mb-0">{children}</p>;
52+
},
53+
ul({ children }) {
54+
return (
55+
<ul className="list-disc pl-6 mb-3 space-y-1">{children}</ul>
56+
);
57+
},
58+
ol({ children }) {
59+
return (
60+
<ol className="list-decimal pl-6 mb-3 space-y-1">{children}</ol>
61+
);
62+
},
63+
li({ children }) {
64+
return <li className="leading-relaxed">{children}</li>;
65+
},
66+
h1({ children }) {
67+
return (
68+
<h1 className="text-xl font-bold mb-3 mt-4 first:mt-0">
69+
{children}
70+
</h1>
71+
);
72+
},
73+
h2({ children }) {
74+
return (
75+
<h2 className="text-lg font-bold mb-2 mt-4 first:mt-0">
76+
{children}
77+
</h2>
78+
);
79+
},
80+
h3({ children }) {
81+
return (
82+
<h3 className="text-base font-semibold mb-2 mt-3 first:mt-0">
83+
{children}
84+
</h3>
85+
);
86+
},
87+
blockquote({ children }) {
88+
return (
89+
<blockquote className="border-l-4 border-primary/30 pl-4 italic text-muted-foreground my-3">
90+
{children}
91+
</blockquote>
92+
);
93+
},
94+
a({ href, children }) {
95+
return (
96+
<a
97+
href={href}
98+
target="_blank"
99+
rel="noopener noreferrer"
100+
className="text-primary hover:underline"
101+
>
102+
{children}
103+
</a>
104+
);
105+
},
106+
table({ children }) {
107+
return (
108+
<div className="overflow-x-auto my-3">
109+
<table className="min-w-full border-collapse border border-border">
110+
{children}
111+
</table>
112+
</div>
113+
);
114+
},
115+
th({ children }) {
116+
return (
117+
<th className="border border-border bg-muted px-3 py-2 text-left font-semibold">
118+
{children}
119+
</th>
120+
);
121+
},
122+
td({ children }) {
123+
return (
124+
<td className="border border-border px-3 py-2">{children}</td>
125+
);
126+
},
127+
hr() {
128+
return <hr className="my-4 border-border" />;
129+
},
130+
}}
131+
>
132+
{content}
133+
</ReactMarkdown>
134+
</div>
135+
);
136+
}
137+
138+
interface CodeBlockProps {
139+
language: string;
140+
children: string;
141+
}
142+
143+
function CodeBlock({ language, children }: CodeBlockProps) {
144+
const [copied, setCopied] = useState(false);
145+
146+
const handleCopy = async () => {
147+
await navigator.clipboard.writeText(children);
148+
setCopied(true);
149+
setTimeout(() => setCopied(false), 2000);
150+
};
151+
152+
return (
153+
<div className="relative group my-3 rounded-lg overflow-hidden">
154+
<div className="flex items-center justify-between px-4 py-2 bg-zinc-800 text-zinc-400 text-xs">
155+
<span>{language}</span>
156+
<button
157+
onClick={handleCopy}
158+
className="flex items-center gap-1 hover:text-zinc-200 transition-colors"
159+
>
160+
{copied ? (
161+
<>
162+
<Check className="w-3.5 h-3.5" />
163+
<span>Copied!</span>
164+
</>
165+
) : (
166+
<>
167+
<Copy className="w-3.5 h-3.5" />
168+
<span>Copy</span>
169+
</>
170+
)}
171+
</button>
172+
</div>
173+
<SyntaxHighlighter
174+
style={oneDark}
175+
language={language}
176+
PreTag="div"
177+
customStyle={{
178+
margin: 0,
179+
borderRadius: 0,
180+
fontSize: "0.875rem",
181+
}}
182+
>
183+
{children}
184+
</SyntaxHighlighter>
185+
</div>
186+
);
187+
}

apps/web/components/chat/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { ChatMessageItem } from "./ChatMessage";
22
export { ChatInput } from "./ChatInput";
3+
export { MarkdownRenderer } from "./MarkdownRenderer";

apps/web/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"dependencies": {
1212
"@radix-ui/react-label": "^2.1.8",
1313
"@radix-ui/react-slot": "^1.2.4",
14+
"@types/react-syntax-highlighter": "^15.5.13",
1415
"@types/three": "^0.181.0",
1516
"axios": "^1.13.2",
1617
"class-variance-authority": "^0.7.1",
@@ -21,6 +22,9 @@
2122
"react": "19.2.0",
2223
"react-dom": "19.2.0",
2324
"react-force-graph-3d": "^1.29.0",
25+
"react-markdown": "^10.1.0",
26+
"react-syntax-highlighter": "^16.1.0",
27+
"remark-gfm": "^4.0.1",
2428
"tailwind-merge": "^3.4.0",
2529
"three": "^0.181.1"
2630
},

0 commit comments

Comments
 (0)