Skip to content

Commit 644430c

Browse files
Merge pull request #3755 from mehmetozguldev/render-markdown-responses
feat: add markdown rendering
2 parents 213d62f + 6b45c1e commit 644430c

File tree

7 files changed

+487
-249
lines changed

7 files changed

+487
-249
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"----- CI ---- used to test the codebase on every commit": "",
1212
"ci": "pnpm lint && pnpm test && pnpm build"
1313
},
14-
"packageManager": "pnpm@10.13.1",
14+
"packageManager": "pnpm@10.23.0",
1515
"engines": {
1616
"node": ">=22",
1717
"pnpm": ">=10 <11"

packages/flashtype/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@
4646
"lucide-react": "^0.542.0",
4747
"react": "^19.2.0",
4848
"react-dom": "^19.2.0",
49+
"react-markdown": "^9.0.1",
4950
"react-resizable-panels": "^2.1.9",
51+
"react-syntax-highlighter": "^15.6.1",
52+
"remark-gfm": "^4.0.0",
5053
"tailwind-merge": "^3.3.1"
5154
},
5255
"devDependencies": {
@@ -56,6 +59,7 @@
5659
"@testing-library/react": "^16.0.0",
5760
"@types/react": "^19.2.2",
5861
"@types/react-dom": "^19.2.2",
62+
"@types/react-syntax-highlighter": "^15.5.13",
5963
"@vitejs/plugin-react": "^5.0.2",
6064
"babel-plugin-react-compiler": "^1.0.0",
6165
"happy-dom": "^18.0.1",

packages/flashtype/src/views/agent-view/chat-message.tsx

Lines changed: 2 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as React from "react";
22
import type { ChatMessage as Msg } from "./chat-types";
33
import { ToolRunList } from "./tool-run-list";
4+
import { MessageBody } from "./markdown/message-body";
45

56
/**
67
* Chat message component with clean typography and spacing.
@@ -75,66 +76,9 @@ export function ChatMessage({
7576
: "text-foreground",
7677
].join(" ")}
7778
>
78-
<MessageBody content={message.content} isUser={isUser} />
79+
<MessageBody content={message.content} />
7980
</div>
8081
)}
8182
</div>
8283
);
8384
}
84-
85-
/**
86-
* Renders message content with lightweight code block styling.
87-
* Plain text uses sans-serif, code blocks use monospace.
88-
*/
89-
function MessageBody({
90-
content,
91-
isUser: _isUser,
92-
}: {
93-
content: string;
94-
isUser: boolean;
95-
}) {
96-
const parts = React.useMemo(() => splitFences(content), [content]);
97-
return (
98-
<div className="space-y-3">
99-
{parts.map((p, i) =>
100-
p.type === "fence" ? (
101-
<div
102-
key={i}
103-
className="rounded-md border border-border/50 bg-muted/30 overflow-hidden"
104-
>
105-
<div className="border-b border-border/40 bg-muted/50 px-3 py-1.5">
106-
<span className="text-xs font-mono font-semibold uppercase tracking-[0.18em] text-muted-foreground">
107-
{p.lang || "code"}
108-
</span>
109-
</div>
110-
<pre className="px-3 py-2.5 text-sm font-mono leading-relaxed text-foreground overflow-x-auto">
111-
{p.body}
112-
</pre>
113-
</div>
114-
) : (
115-
<div key={i} className="text-sm leading-relaxed whitespace-pre-wrap">
116-
{p.body}
117-
</div>
118-
),
119-
)}
120-
</div>
121-
);
122-
}
123-
124-
type Part = { type: "text" | "fence"; body: string; lang?: string };
125-
126-
function splitFences(input: string): Part[] {
127-
const out: Part[] = [];
128-
const fence = /```(\w+)?\n([\s\S]*?)\n```/g;
129-
let last = 0;
130-
let m: RegExpExecArray | null;
131-
while ((m = fence.exec(input))) {
132-
const [full, lang, body] = m;
133-
if (m.index > last)
134-
out.push({ type: "text", body: input.slice(last, m.index) });
135-
out.push({ type: "fence", lang, body });
136-
last = m.index + full.length;
137-
}
138-
if (last < input.length) out.push({ type: "text", body: input.slice(last) });
139-
return out;
140-
}

packages/flashtype/src/views/agent-view/conversation-message.tsx

Lines changed: 1 addition & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
import { toPlainText } from "@lix-js/sdk/dependency/zettel-ast";
1010
import type { ZettelDoc } from "@lix-js/sdk/dependency/zettel-ast";
1111
import { ChevronRight } from "lucide-react";
12+
import { MessageBody } from "./markdown/message-body";
1213

1314
type ConversationMessageProps = {
1415
message: AgentConversationMessage;
@@ -93,35 +94,6 @@ function UserMessage({ content }: UserMessageProps) {
9394
);
9495
}
9596

96-
function MessageBody({ content }: { content: string }) {
97-
const parts = React.useMemo(() => splitFences(content), [content]);
98-
return (
99-
<div className="space-y-2">
100-
{parts.map((part, index) =>
101-
part.type === "fence" ? (
102-
<div
103-
key={index}
104-
className="overflow-hidden rounded-md border border-border/50 bg-muted/30"
105-
>
106-
<div className="border-b border-border/40 bg-muted/50 px-3 py-1.5">
107-
<span className="text-xs font-mono font-semibold uppercase tracking-[0.18em] text-muted-foreground">
108-
{part.lang || "code"}
109-
</span>
110-
</div>
111-
<pre className="overflow-x-auto px-3 py-2.5 text-sm font-mono leading-relaxed text-foreground">
112-
{part.body}
113-
</pre>
114-
</div>
115-
) : (
116-
<div key={index} className="text-sm leading-relaxed">
117-
{part.body}
118-
</div>
119-
),
120-
)}
121-
</div>
122-
);
123-
}
124-
12597
function StepList({ steps }: { steps: AgentStep[] }) {
12698
return (
12799
<div className="flex flex-col">
@@ -364,28 +336,6 @@ function formatToolError(value: unknown): string | undefined {
364336
}
365337
}
366338

367-
type Part = { type: "text" | "fence"; body: string; lang?: string };
368-
369-
// Lightweight fence parser so we can render ``` blocks with structured styling.
370-
function splitFences(input: string): Part[] {
371-
const out: Part[] = [];
372-
const fence = /```(\w+)?\n([\s\S]*?)\n```/g;
373-
let last = 0;
374-
let match: RegExpExecArray | null;
375-
while ((match = fence.exec(input))) {
376-
const [full, lang, body] = match;
377-
if (match.index > last) {
378-
out.push({ type: "text", body: input.slice(last, match.index) });
379-
}
380-
out.push({ type: "fence", lang, body });
381-
last = match.index + full.length;
382-
}
383-
if (last < input.length) {
384-
out.push({ type: "text", body: input.slice(last) });
385-
}
386-
return out;
387-
}
388-
389339
/**
390340
* Converts verbose model thinking text into a concise single-line summary.
391341
*
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import * as React from "react";
2+
import { ChevronDown, ChevronUp, Copy, Check } from "lucide-react";
3+
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
4+
import { solarizedlight } from "react-syntax-highlighter/dist/esm/styles/prism";
5+
6+
export function CodeBlock({
7+
code,
8+
language,
9+
}: {
10+
code: string;
11+
language: string;
12+
}) {
13+
const lineCount = React.useMemo(() => code.split("\n").length, [code]);
14+
const isLong = lineCount > 5;
15+
const [isExpanded, setIsExpanded] = React.useState(!isLong);
16+
const [copied, setCopied] = React.useState(false);
17+
18+
const handleCopy = React.useCallback(async () => {
19+
await navigator.clipboard.writeText(code);
20+
setCopied(true);
21+
setTimeout(() => setCopied(false), 2000);
22+
}, [code]);
23+
24+
const previewLines = React.useMemo(() => {
25+
if (!isLong || isExpanded) return code;
26+
return code.split("\n").slice(0, 5).join("\n");
27+
}, [code, isLong, isExpanded]);
28+
29+
return (
30+
<div
31+
className="overflow-hidden rounded-md border border-border/50 bg-muted/30"
32+
role="region"
33+
aria-label={`Code block in ${language || "plain text"}`}
34+
>
35+
<div className="flex items-center justify-between border-b border-border/40 bg-muted/50 px-3 py-1.5">
36+
<span className="text-[0.625rem] font-mono font-normal text-muted-foreground/60">
37+
{language || "code"}
38+
</span>
39+
<button
40+
onClick={handleCopy}
41+
className="text-muted-foreground hover:text-foreground transition-colors"
42+
aria-label="Copy code"
43+
title={copied ? "Copied!" : "Copy code"}
44+
>
45+
{copied ? (
46+
<Check className="h-3.5 w-3.5" />
47+
) : (
48+
<Copy className="h-3.5 w-3.5" />
49+
)}
50+
</button>
51+
</div>
52+
<div className="overflow-x-auto relative">
53+
<style>{`
54+
.syntax-highlighter-with-gutter > div {
55+
position: relative;
56+
}
57+
.syntax-highlighter-with-gutter > div::before {
58+
content: '';
59+
position: absolute;
60+
left: 0;
61+
top: 0;
62+
bottom: 0;
63+
width: 3.5rem;
64+
background: #fdf6e3;
65+
border-right: 1px solid #e5d5b2;
66+
pointer-events: none;
67+
z-index: 0;
68+
}
69+
.syntax-highlighter-with-gutter .linenumber {
70+
position: sticky !important;
71+
left: 0 !important;
72+
padding-right: 1rem !important;
73+
min-width: 2.5rem !important;
74+
text-align: right !important;
75+
user-select: none !important;
76+
display: inline-block !important;
77+
z-index: 1 !important;
78+
position: relative !important;
79+
}
80+
`}</style>
81+
<SyntaxHighlighter
82+
language={language || "text"}
83+
style={solarizedlight}
84+
PreTag="div"
85+
showLineNumbers
86+
className="syntax-highlighter-with-gutter"
87+
customStyle={{
88+
margin: 0,
89+
padding: "0.5rem",
90+
fontSize: "0.8125rem",
91+
lineHeight: "1.4",
92+
background: "transparent",
93+
}}
94+
codeTagProps={{
95+
className: "font-mono text-xs",
96+
}}
97+
>
98+
{isExpanded ? code : previewLines}
99+
</SyntaxHighlighter>
100+
</div>
101+
{isLong && !isExpanded && (
102+
<div className="relative -mt-12 pointer-events-none">
103+
<div className="h-12 bg-gradient-to-t from-muted/80 to-transparent" />
104+
</div>
105+
)}
106+
{isLong && (
107+
<div className="border-t border-border/40 bg-muted/50 px-3 py-2 flex justify-center">
108+
<button
109+
onClick={() => setIsExpanded(!isExpanded)}
110+
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
111+
aria-label={isExpanded ? "Collapse code block" : `Expand code block (${lineCount} lines)`}
112+
>
113+
{isExpanded ? (
114+
<>
115+
Show Less <ChevronUp className="h-3 w-3" />
116+
</>
117+
) : (
118+
<>
119+
Show More ({lineCount} lines) <ChevronDown className="h-3 w-3" />
120+
</>
121+
)}
122+
</button>
123+
</div>
124+
)}
125+
</div>
126+
);
127+
}

0 commit comments

Comments
 (0)