Render the assistant's replies as Markdown (#118)
Build image / build-and-push (push) Successful in 7s
Build image / build-and-push (push) Successful in 7s
Render assistant chat output as GFM Markdown (tables, lists, code, headings), lazy-loaded so the ~150 KB renderer only ships when an assistant message shows. Hardened per review: no <img> (exfiltration-beacon guard), no raw HTML, error-boundary + stale-chunk recovery around the lazy chunk, GFM column alignment, and memoized parsing. Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #118.
This commit is contained in:
Generated
+1469
-5
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,8 @@
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^9.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"zod": "^3.24.1",
|
||||
"zustand": "^5.0.14"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Component, Suspense, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
@@ -14,8 +14,31 @@ import {
|
||||
type AgentTurn,
|
||||
} from '@/lib/agent'
|
||||
import { useUndo } from '@/lib/history'
|
||||
import { lazyPage } from '@/lib/lazyPage'
|
||||
import { UndoButton } from './UndoButton'
|
||||
|
||||
// Lazy so the markdown renderer + its ecosystem (~150 KB) loads only when an
|
||||
// assistant message actually renders, not for everyone who opens the editor.
|
||||
// lazyPage adds the stale-chunk recovery a plain lazy() lacks — a post-deploy
|
||||
// chunk 404 would otherwise permanently break the assistant.
|
||||
const MarkdownMessage = lazyPage(() => import('./MarkdownMessage'), 'MarkdownMessage')
|
||||
|
||||
/**
|
||||
* Falls back to the raw message text if the markdown chunk can't load (a
|
||||
* non-recoverable 404) or the renderer throws — a garbled reply should degrade to
|
||||
* readable text, never take the whole editor down. Suspense handles the loading
|
||||
* phase; this handles the failure one.
|
||||
*/
|
||||
class MarkdownBoundary extends Component<{ fallback: ReactNode; children: ReactNode }, { failed: boolean }> {
|
||||
state = { failed: false }
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true }
|
||||
}
|
||||
render() {
|
||||
return this.state.failed ? this.props.fallback : this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Talk to the garden assistant, in the editor beside the canvas.
|
||||
*
|
||||
@@ -106,8 +129,8 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
|
||||
onError: (err) => setError(errorMessage(err, "Couldn't clear the conversation.")),
|
||||
})
|
||||
}
|
||||
disabled={clear.isPending}
|
||||
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
disabled={clear.isPending || !!pending}
|
||||
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40 disabled:opacity-50"
|
||||
>
|
||||
Start over
|
||||
</button>
|
||||
@@ -227,11 +250,27 @@ function Bubble({
|
||||
<div className={cn('flex flex-col', mine ? 'items-end' : 'items-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[90%] whitespace-pre-wrap rounded-lg px-2.5 py-2 text-sm',
|
||||
mine ? 'bg-accent/15 text-fg' : 'border border-border text-fg',
|
||||
'rounded-lg px-2.5 py-2 text-sm',
|
||||
// The user's own text is literal (their `*` shouldn't become a bullet)
|
||||
// and hugs the right; the assistant's Markdown is rendered and gets the
|
||||
// full width so a table has room.
|
||||
mine
|
||||
? 'max-w-[90%] whitespace-pre-wrap bg-accent/15 text-fg'
|
||||
: 'w-full border border-border text-fg',
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
{mine ? (
|
||||
body
|
||||
) : (
|
||||
// Show the raw text until the renderer chunk arrives (Suspense), and fall
|
||||
// back to it if the chunk can't load or the renderer throws (boundary) —
|
||||
// either way the message is readable, never blank and never a crash.
|
||||
<MarkdownBoundary fallback={<span className="whitespace-pre-wrap">{body}</span>}>
|
||||
<Suspense fallback={<span className="whitespace-pre-wrap">{body}</span>}>
|
||||
<MarkdownMessage>{body}</MarkdownMessage>
|
||||
</Suspense>
|
||||
</MarkdownBoundary>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { MarkdownMessage } from './MarkdownMessage'
|
||||
|
||||
// renderToStaticMarkup needs no DOM, so this runs in the default (node) env and
|
||||
// proves the assistant's Markdown — GFM tables in particular — actually renders.
|
||||
function render(md: string): string {
|
||||
return renderToStaticMarkup(<MarkdownMessage>{md}</MarkdownMessage>)
|
||||
}
|
||||
|
||||
describe('MarkdownMessage', () => {
|
||||
it('renders a GFM table with styled cells', () => {
|
||||
const html = render('| Bed | Plant |\n| --- | --- |\n| North | Garlic |')
|
||||
expect(html).toContain('<table')
|
||||
expect(html).toContain('border-collapse')
|
||||
expect(html).toContain('<th')
|
||||
expect(html).toContain('<td')
|
||||
expect(html).toContain('Garlic')
|
||||
// Wide tables scroll inside their own box rather than blowing out the bubble.
|
||||
expect(html).toContain('overflow-x-auto')
|
||||
})
|
||||
|
||||
it('renders inline formatting and lists', () => {
|
||||
const html = render('**bold** and *italic*\n\n- one\n- two')
|
||||
expect(html).toContain('<strong')
|
||||
expect(html).toContain('<em')
|
||||
expect(html).toContain('<ul')
|
||||
expect(html).toContain('<li')
|
||||
})
|
||||
|
||||
it('does not emit raw HTML from the model (no rehype-raw)', () => {
|
||||
const html = render('Hi <script>alert(1)</script> <b>x</b>')
|
||||
expect(html).not.toContain('<script>')
|
||||
expect(html).not.toContain('<b>x</b>') // the literal tag is escaped, not rendered
|
||||
})
|
||||
|
||||
it('opens links safely in a new tab', () => {
|
||||
const html = render('[seeds](https://example.com)')
|
||||
expect(html).toContain('href="https://example.com"')
|
||||
expect(html).toContain('rel="noopener noreferrer"')
|
||||
})
|
||||
|
||||
it('does not render images (no auto-loading exfiltration beacon)', () => {
|
||||
// A prompt-injected reply could embed ``; the browser
|
||||
// would auto-fetch it, leaking that the message was viewed (and anything smuggled
|
||||
// into the URL). We forbid <img> entirely, so the beacon never fires.
|
||||
const html = render('before  after')
|
||||
expect(html).not.toContain('<img')
|
||||
expect(html).not.toContain('evil.example')
|
||||
// Surrounding prose still renders.
|
||||
expect(html).toContain('before')
|
||||
expect(html).toContain('after')
|
||||
})
|
||||
|
||||
it('honours GFM column alignment', () => {
|
||||
const html = render('| L | C | R |\n| :-- | :--: | --: |\n| a | b | c |')
|
||||
expect(html).toContain('text-align:center')
|
||||
expect(html).toContain('text-align:right')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { memo, type ReactNode } from 'react'
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
// Hoisted so they're not re-created every render (which would defeat both React's
|
||||
// and ReactMarkdown's memoization).
|
||||
const remarkPlugins = [remarkGfm]
|
||||
const CODE_BLOCK = /language-/
|
||||
|
||||
// The assistant's replies are never trusted markup: their content can be steered
|
||||
// by anything the agent read (a shared garden's notes, a seed vendor page). So we
|
||||
// render Markdown but NOT raw HTML (no rehype-raw), and — belt to that — forbid
|
||||
// <img>, whose auto-loading `src` is a prompt-injection exfiltration beacon
|
||||
// (``); the assistant has no reason to emit images.
|
||||
const disallowedElements = ['img']
|
||||
|
||||
// Tailwind's reset strips default list/table styling, so every element the
|
||||
// assistant actually uses is restyled here, scaled for a chat bubble. Wide
|
||||
// content (tables, code) scrolls in its own box so the bubble never blows out.
|
||||
const bigHeading = ({ children }: { children?: ReactNode }) => (
|
||||
<h4 className="mb-1 mt-2 text-sm font-semibold first:mt-0">{children}</h4>
|
||||
)
|
||||
const smallHeading = ({ children }: { children?: ReactNode }) => (
|
||||
<h5 className="mb-1 mt-1.5 text-xs font-semibold uppercase tracking-wide text-muted first:mt-0">
|
||||
{children}
|
||||
</h5>
|
||||
)
|
||||
|
||||
const components: Components = {
|
||||
p: ({ children }) => <p className="my-1.5 first:mt-0 last:mb-0">{children}</p>,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent-strong underline underline-offset-2"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => <ul className="my-1.5 list-disc pl-5">{children}</ul>,
|
||||
ol: ({ children, start }) => (
|
||||
<ol start={start} className="my-1.5 list-decimal pl-5">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="my-0.5">{children}</li>,
|
||||
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
h1: bigHeading,
|
||||
h2: bigHeading,
|
||||
h3: bigHeading,
|
||||
h4: smallHeading,
|
||||
h5: smallHeading,
|
||||
h6: smallHeading,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="my-1.5 border-l-2 border-border pl-2 text-muted">{children}</blockquote>
|
||||
),
|
||||
hr: () => <hr className="my-2 border-border" />,
|
||||
code: ({ className, children }) => {
|
||||
// A fenced block is wrapped by <pre> (styled below) and carries either a
|
||||
// language- class or a trailing newline; inline code is a single-line bare
|
||||
// <code> and gets the pill treatment. (The newline check catches fences with
|
||||
// no info-string, which have no language- class.)
|
||||
const isBlock = CODE_BLOCK.test(className ?? '') || String(children).includes('\n')
|
||||
if (isBlock) return <code className={className}>{children}</code>
|
||||
return (
|
||||
<code className="rounded bg-border/60 px-1 py-0.5 font-mono text-[0.85em]">{children}</code>
|
||||
)
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="my-1.5 overflow-x-auto rounded-md bg-border/40 p-2 font-mono text-xs">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="my-1.5 overflow-x-auto">
|
||||
<table className="w-full border-collapse text-xs">{children}</table>
|
||||
</div>
|
||||
),
|
||||
// Pass `style` through: GFM column alignment (`:---:` / `---:`) arrives as
|
||||
// style.textAlign, and dropping it would silently discard it.
|
||||
th: ({ children, style }) => (
|
||||
<th style={style} className="border border-border px-2 py-1 font-semibold">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children, style }) => (
|
||||
<td style={style} className="border border-border px-2 py-1 align-top">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
}
|
||||
|
||||
/** Render one assistant message body as Markdown (GFM). Memoized so typing in the
|
||||
* composer doesn't re-parse every message in the thread. */
|
||||
export const MarkdownMessage = memo(function MarkdownMessage({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="leading-relaxed">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
disallowedElements={disallowedElements}
|
||||
components={components}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { lazy, type ComponentType } from 'react'
|
||||
|
||||
/**
|
||||
* Lazily load a component by its named export, with recovery for the stale-chunk
|
||||
* problem. A push to main redeploys, so a still-open app references chunk hashes
|
||||
* the server has just replaced; that import 404s and React.lazy MEMOIZES the
|
||||
* rejection, so a "Try again" can never recover — the user is stuck until a manual
|
||||
* hard reload. On the first such failure we reload once (fetching the fresh index
|
||||
* + hashes); a session flag stops a reload loop, and a success clears it so a
|
||||
* later genuine failure can reload again.
|
||||
*
|
||||
* Shared by the route splits (router.tsx) and any feature-level lazy load (the
|
||||
* assistant's Markdown renderer), so they all get the same recovery.
|
||||
*/
|
||||
export function lazyPage<M, K extends keyof M>(load: () => Promise<M>, name: K) {
|
||||
// Preserve the component's own prop type so callers keep type-checked props
|
||||
// (e.g. MarkdownMessage's `children: string`), rather than erasing to `{}`.
|
||||
type C = M[K] extends ComponentType<infer P> ? ComponentType<P> : never
|
||||
return lazy<C>(async () => {
|
||||
try {
|
||||
const mod = await load()
|
||||
try {
|
||||
sessionStorage.removeItem('pansy:chunk-reload')
|
||||
} catch {
|
||||
/* storage unavailable — fine */
|
||||
}
|
||||
return { default: mod[name] as C }
|
||||
} catch (err) {
|
||||
try {
|
||||
if (!sessionStorage.getItem('pansy:chunk-reload')) {
|
||||
sessionStorage.setItem('pansy:chunk-reload', '1')
|
||||
window.location.reload()
|
||||
return await new Promise<{ default: C }>(() => {}) // hold for the reload
|
||||
}
|
||||
} catch {
|
||||
/* storage unavailable — fall through to surface the error */
|
||||
}
|
||||
throw err // already reloaded once (or can't); let the error boundary show it
|
||||
}
|
||||
})
|
||||
}
|
||||
+1
-33
@@ -1,4 +1,3 @@
|
||||
import { lazy, type ComponentType } from 'react'
|
||||
import {
|
||||
createRootRouteWithContext,
|
||||
createRoute,
|
||||
@@ -15,38 +14,7 @@ import { meQueryOptions } from '@/lib/auth'
|
||||
import { queryClient } from '@/lib/queryClient'
|
||||
import { safeRedirectPath } from '@/lib/redirect'
|
||||
import { getLastGardenId } from '@/lib/lastGarden'
|
||||
|
||||
// Lazily load a page by its named export, with recovery for the stale-chunk
|
||||
// problem. A push to main redeploys, so a still-open app references chunk hashes
|
||||
// the server has just replaced; that import 404s and React.lazy MEMOIZES the
|
||||
// rejection, so RouteError's "Try again" (router.invalidate) can never recover —
|
||||
// the user is stuck until a manual hard reload. On the first such failure we
|
||||
// reload once (fetching the fresh index + hashes); a session flag stops a reload
|
||||
// loop, and a success clears it so a later genuine failure can reload again.
|
||||
function lazyPage<M, K extends keyof M>(load: () => Promise<M>, name: K) {
|
||||
return lazy(async () => {
|
||||
try {
|
||||
const mod = await load()
|
||||
try {
|
||||
sessionStorage.removeItem('pansy:chunk-reload')
|
||||
} catch {
|
||||
/* storage unavailable — fine */
|
||||
}
|
||||
return { default: mod[name] as ComponentType }
|
||||
} catch (err) {
|
||||
try {
|
||||
if (!sessionStorage.getItem('pansy:chunk-reload')) {
|
||||
sessionStorage.setItem('pansy:chunk-reload', '1')
|
||||
window.location.reload()
|
||||
return await new Promise<{ default: ComponentType }>(() => {}) // hold for the reload
|
||||
}
|
||||
} catch {
|
||||
/* storage unavailable — fall through to surface the error */
|
||||
}
|
||||
throw err // already reloaded once (or can't); let the error boundary show it
|
||||
}
|
||||
})
|
||||
}
|
||||
import { lazyPage } from '@/lib/lazyPage'
|
||||
|
||||
// Code-split the heavier / deeper routes so a phone on cell data doesn't download
|
||||
// the whole app (notably the canvas editor with its gesture + geometry deps)
|
||||
|
||||
+16
-13
@@ -31,22 +31,25 @@ export default defineConfig(({ mode }) => {
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// One vendor chunk for ALL node_modules: it's rarely-changing, so it
|
||||
// caches across app deploys while the tiny app chunk churns. Kept as a
|
||||
// SINGLE chunk on purpose — splitting react-dom/scheduler into their own
|
||||
// chunk reorders their module init across chunk boundaries and breaks
|
||||
// React 19 at load ("Cannot set 'Activity' of undefined"). The heavy
|
||||
// routes are code-split separately via React.lazy (router.tsx), which is
|
||||
// where the real first-paint win is.
|
||||
// The app-wide core goes in ONE cached vendor chunk (rarely changes, so
|
||||
// it survives deploys while the tiny app chunk churns). react + react-dom
|
||||
// + scheduler MUST stay together — splitting react-dom into its own chunk
|
||||
// reorders module init across chunk boundaries and breaks React 19 at
|
||||
// load ("Cannot set 'Activity' of undefined"). Everything else — the
|
||||
// gesture engine, the assistant's markdown renderer + its ecosystem —
|
||||
// rides with whatever imports it, so a lazily-loaded route/feature keeps
|
||||
// it out of the eager first paint. The routes are code-split via
|
||||
// React.lazy (router.tsx), where the real first-paint win is.
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) return undefined
|
||||
// Editor-only libs (the gesture engine) ride with the lazy editor
|
||||
// chunk instead of the eager vendor one, so the first paint doesn't
|
||||
// pay for code only the canvas needs. Everything else — react, tanstack
|
||||
// and the rest — stays in ONE vendor chunk; splitting react-dom out
|
||||
// reorders its init across chunks and breaks React 19 at load.
|
||||
if (id.includes('@use-gesture')) return undefined
|
||||
if (
|
||||
/[/\\]node_modules[/\\](react|react-dom|scheduler|@tanstack|zustand|zod|clsx|tailwind-merge)[/\\]/.test(
|
||||
id,
|
||||
)
|
||||
) {
|
||||
return 'vendor'
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user