Lightweight Markdown Renderer for Chat Messages (Rebased)#27
Lightweight Markdown Renderer for Chat Messages (Rebased)#27kevinthai256 wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR consolidates the lightweight inline markdown renderer by extracting it from StudyPanel.tsx into a shared utility file (frontend/src/lib/markdown.tsx), and then applies it to assistant messages in MessageBubble.tsx. This means chat responses from the agent are now rendered with formatted markdown (bold, headings, lists, inline code, fenced code blocks) rather than as raw plain text.
Changes:
- New shared markdown utility (
lib/markdown.tsx) with exportedMarkdownLineandrenderInline. StudyPanel.tsxnow importsMarkdownLinefrom the shared lib instead of defining it locally.MessageBubble.tsxsplits assistant message content by newline and renders each line throughMarkdownLine.
Reviewed changes
Copilot reviewed 3 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
frontend/src/lib/markdown.tsx |
New shared file containing MarkdownLine and renderInline extracted from StudyPanel.tsx |
frontend/src/components/StudyPanel.tsx |
Removes local MarkdownLine and renderInline definitions, imports from shared lib |
frontend/src/components/MessageBubble.tsx |
Applies markdown rendering to assistant message content |
frontend/package-lock.json |
Lockfile updated with "peer": true markers on several packages (npm metadata, no functional change) |
Files not reviewed (1)
- frontend/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export function MarkdownLine({ line }: { line: string }) { | ||
| const trimmed = line.trim() | ||
| if (!trimmed) return <div className="h-3" /> | ||
|
|
||
| if (trimmed.startsWith('### ')) | ||
| return <h3 className="text-sm font-semibold text-text-100 mt-4 mb-1.5">{renderInline(trimmed.slice(4))}</h3> | ||
| if (trimmed.startsWith('## ')) | ||
| return <h2 className="text-base font-semibold text-text-100 mt-5 mb-2">{renderInline(trimmed.slice(3))}</h2> | ||
| if (trimmed.startsWith('# ')) | ||
| return <h1 className="text-lg font-bold text-text-100 mt-5 mb-2">{renderInline(trimmed.slice(2))}</h1> | ||
| if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) | ||
| return <li className="text-sm text-text-200 ml-4 mb-1 list-disc">{renderInline(trimmed.slice(2))}</li> | ||
| if (/^\d+\.\s/.test(trimmed)) { | ||
| const text = trimmed.replace(/^\d+\.\s/, '') | ||
| return <li className="text-sm text-text-200 ml-4 mb-1 list-decimal">{renderInline(text)}</li> | ||
| } | ||
| if (trimmed.startsWith('```')) | ||
| return <div className="font-mono text-xs bg-bg-200 px-3 py-1 rounded text-text-300">{trimmed.slice(3)}</div> | ||
|
|
||
| return <p className="text-sm text-text-200 mb-2 leading-relaxed">{renderInline(trimmed)}</p> | ||
| } | ||
|
|
There was a problem hiding this comment.
The triple-backtick handler only matches the opening fence line — it renders only the text after the ``` on that same line (e.g. the language identifier). Lines inside the fenced block are then each processed independently as regular MarkdownLine entries, so they get wrapped in <p> tags and have inline bold/code patterns applied to them. The closing ``` line renders as an empty styled <div> because trimmed.slice(3) is an empty string.
This means multi-line fenced code blocks are not rendered correctly: interior lines are not preserved as literal code text, and inline markdown inside those lines will still be parsed. If the content rendered in MessageBubble is expected to include multi-line code blocks, this will produce broken output. Consider tracking a "inside code block" state across lines or using a pre-pass that groups fenced blocks before rendering individual lines.
| export function MarkdownLine({ line }: { line: string }) { | |
| const trimmed = line.trim() | |
| if (!trimmed) return <div className="h-3" /> | |
| if (trimmed.startsWith('### ')) | |
| return <h3 className="text-sm font-semibold text-text-100 mt-4 mb-1.5">{renderInline(trimmed.slice(4))}</h3> | |
| if (trimmed.startsWith('## ')) | |
| return <h2 className="text-base font-semibold text-text-100 mt-5 mb-2">{renderInline(trimmed.slice(3))}</h2> | |
| if (trimmed.startsWith('# ')) | |
| return <h1 className="text-lg font-bold text-text-100 mt-5 mb-2">{renderInline(trimmed.slice(2))}</h1> | |
| if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) | |
| return <li className="text-sm text-text-200 ml-4 mb-1 list-disc">{renderInline(trimmed.slice(2))}</li> | |
| if (/^\d+\.\s/.test(trimmed)) { | |
| const text = trimmed.replace(/^\d+\.\s/, '') | |
| return <li className="text-sm text-text-200 ml-4 mb-1 list-decimal">{renderInline(text)}</li> | |
| } | |
| if (trimmed.startsWith('```')) | |
| return <div className="font-mono text-xs bg-bg-200 px-3 py-1 rounded text-text-300">{trimmed.slice(3)}</div> | |
| return <p className="text-sm text-text-200 mb-2 leading-relaxed">{renderInline(trimmed)}</p> | |
| } | |
| // Track whether we're currently inside a fenced code block across successive lines. | |
| let inCodeBlock = false | |
| export function MarkdownLine({ line }: { line: string }) { | |
| const trimmed = line.trim() | |
| // Handle fenced code block fences before other markdown constructs. | |
| if (trimmed.startsWith('```')) { | |
| const fenceContent = trimmed.slice(3).trim() | |
| // If we're not in a code block yet, this is an opening fence. | |
| if (!inCodeBlock) { | |
| // Single-line fenced code block: ```code here``` | |
| if (fenceContent) { | |
| // Do not enter code-block mode; render just this line as code. | |
| return ( | |
| <div className="font-mono text-xs bg-bg-200 px-3 py-1 rounded text-text-300"> | |
| {fenceContent} | |
| </div> | |
| ) | |
| } | |
| // Multi-line fenced block opening (e.g. ``` or ```lang) – start code-block mode, | |
| // but do not render the fence line itself. | |
| inCodeBlock = true | |
| return null | |
| } | |
| // If we are already in a code block, this is a closing fence. | |
| inCodeBlock = false | |
| // Do not render the closing fence line. | |
| return null | |
| } | |
| // Inside a multi-line fenced code block: render lines as literal code (no inline markdown). | |
| if (inCodeBlock) { | |
| return ( | |
| <div className="font-mono text-xs bg-bg-200 px-3 py-1 rounded text-text-300"> | |
| {line} | |
| </div> | |
| ) | |
| } | |
| // Outside of code blocks, keep existing behavior. | |
| if (!trimmed) return <div className="h-3" /> | |
| if (trimmed.startsWith('### ')) | |
| return <h3 className="text-sm font-semibold text-text-100 mt-4 mb-1.5">{renderInline(trimmed.slice(4))}</h3> | |
| if (trimmed.startsWith('## ')) | |
| return <h2 className="text-base font-semibold text-text-100 mt-5 mb-2">{renderInline(trimmed.slice(3))}</h2> | |
| if (trimmed.startsWith('# ')) | |
| return <h1 className="text-lg font-bold text-text-100 mt-5 mb-2">{renderInline(trimmed.slice(2))}</h1> | |
| if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) | |
| return <li className="text-sm text-text-200 ml-4 mb-1 list-disc">{renderInline(trimmed.slice(2))}</li> | |
| if (/^\d+\.\s/.test(trimmed)) { | |
| const text = trimmed.replace(/^\d+\.\s/, '') | |
| return <li className="text-sm text-text-200 ml-4 mb-1 list-decimal">{renderInline(text)}</li> | |
| } | |
| return <p className="text-sm text-text-200 mb-2 leading-relaxed">{renderInline(trimmed)}</p> | |
| } |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
CI is failing due to 180 ruff linting/formatting errors. This needs to be fixed before this PR can be reviewed. Before submitting a PR, always run these locally: ruff check src/ --fix
ruff format src/Then verify everything passes cleanly: ruff check src/ && ruff format --check src/One thing ruff won't fix automatically — you have imports placed inside # ❌ Don't do this
try:
...
except Exception as e:
import structlog
...
# ✅ Do this
import structlog
try:
...
except Exception as e:
...Fix the remaining errors, push the changes, and re-request review once CI is green. |
|
Good work Kevin — clean extraction into a shared util, this is the right pattern. Two things before this can merge:
Once the conflicts are resolved this is good to go. 👍 |
…licts Co-authored-by: Cursor <cursoragent@cursor.com>
Code ReviewCannot merge — all 4 CI checks are failing. The good news: the failures are pre-existing issues in the codebase (unused vars, What's good
Issues to fix1.
Fix: split into two files:
Then update 2.
import type { ReactElement } from 'react'
// instead of JSX.Element3. Pre-existing CI failures (not this PR's fault, but blocking) The frontend lint is failing on files like Action items
Once the markdown.tsx structure issue is fixed, this PR's own changes will be clean. |
StatusCI: All 4 checks failing. Only Copilot has reviewed — needs a human reviewer. This PR needs:
Not mergeable until human-reviewed and CI is green. |
Status
Chat messages from the agent no longer render as plain text. Markdown formatting (bold, lists, code blocks, links) is parsed, preventing users from seeing raw ** bold ** and ## headers.
Changes
Replicated lightweight markdown renderer from study panel for agentic message bubbles.
Modified files:
MessageBubble.tsxNext Steps
Consolidate duplicate code segments by exporting MarkdownLine, renderInline, other light markdown functions to a shared component util.