Skip to content

Lightweight Markdown Renderer for Chat Messages (Rebased)#27

Open
kevinthai256 wants to merge 5 commits into
mainfrom
feature/render-chat-markdown
Open

Lightweight Markdown Renderer for Chat Messages (Rebased)#27
kevinthai256 wants to merge 5 commits into
mainfrom
feature/render-chat-markdown

Conversation

@kevinthai256

Copy link
Copy Markdown

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.tsx

Next Steps

Consolidate duplicate code segments by exporting MarkdownLine, renderInline, other light markdown functions to a shared component util.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 exported MarkdownLine and renderInline.
  • StudyPanel.tsx now imports MarkdownLine from the shared lib instead of defining it locally.
  • MessageBubble.tsx splits assistant message content by newline and renders each line through MarkdownLine.

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.

Comment thread frontend/src/lib/markdown.tsx Outdated
Comment thread frontend/src/lib/markdown.tsx Outdated
Comment on lines +1 to +22
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>
}

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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>
}

Copilot uses AI. Check for mistakes.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@okosei04

okosei04 commented Mar 6, 2026

Copy link
Copy Markdown

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 try/except blocks. Move all imports to the top of the file:

# ❌ 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.

@Youdahe123

Copy link
Copy Markdown
Contributor

Good work Kevin — clean extraction into a shared util, this is the right pattern. Two things before this can merge:

  1. Merge conflicts — this needs a rebase onto main. Can you pull latest and resolve?
  2. Minor: add a newline at the end of lib/markdown.tsx (missing EOF newline).

Once the conflicts are resolved this is good to go. 👍

@Youdahe123

Copy link
Copy Markdown
Contributor

Code Review

Cannot merge — all 4 CI checks are failing. The good news: the failures are pre-existing issues in the codebase (unused vars, any types, etc. in files this PR doesn't touch), not caused by this PR's changes. However, the branch protection requires all checks to pass before merging.

What's good

  • Correctly extracts the duplicated markdown logic from StudyPanel.tsx into a shared frontend/src/lib/markdown.tsx
  • Both MessageBubble.tsx and StudyPanel.tsx now import from the shared lib — clean deduplication
  • The implementation itself is correct

Issues to fix

1. react-refresh/only-export-components will fail on markdown.tsx

frontend/src/lib/markdown.tsx exports both a React component (MarkdownLine) and a plain function (renderInline). ESLint's react-refresh/only-export-components rule blocks this because it breaks Vite's fast refresh.

Fix: split into two files:

  • frontend/src/lib/markdown.tsx — exports only MarkdownLine (the component)
  • frontend/src/lib/markdownUtils.ts — exports renderInline (no JSX, so .ts not .tsx)

Then update markdown.tsx to import renderInline from markdownUtils.ts internally, and update any callers that import renderInline directly.

2. JSX.Element is deprecated in React 19

renderInline uses JSX.Element in its return type. In React 19, prefer ReactElement from 'react':

import type { ReactElement } from 'react'
// instead of JSX.Element

3. Pre-existing CI failures (not this PR's fault, but blocking)

The frontend lint is failing on files like ConceptGraph3D.tsx, DocumentsPanel.tsx, FlashcardReview.tsx, etc. with no-explicit-any, no-unused-vars, and hook rule violations. These need to be fixed somewhere in the codebase before any PR can land. Worth opening a separate cleanup PR for the worst offenders.

Action items

  1. Split markdown.tsx into component + utility files
  2. Replace JSX.Element with ReactElement
  3. Re-push so CI re-runs

Once the markdown.tsx structure issue is fixed, this PR's own changes will be clean.

@Youdahe123 Youdahe123 mentioned this pull request May 5, 2026
5 tasks
@Youdahe123

Copy link
Copy Markdown
Contributor

Status

CI: All 4 checks failing. Only Copilot has reviewed — needs a human reviewer.

This PR needs:

  1. A human review (Copilot review on file, but no human approval)
  2. A rebase on main once fix: backend-lint and backend-test CI (#35) #36 and fix: frontend lint tests ci #38 land — CI failures here are pre-existing lint issues resolved in those PRs

Not mergeable until human-reviewed and CI is green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants