diff --git a/web/src/editor/ChatPanel.tsx b/web/src/editor/ChatPanel.tsx
index 489845c..72edcd7 100644
--- a/web/src/editor/ChatPanel.tsx
+++ b/web/src/editor/ChatPanel.tsx
@@ -1,4 +1,4 @@
-import { Suspense, lazy, 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,13 +14,30 @@ 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.
-const MarkdownMessage = lazy(() =>
- import('./MarkdownMessage').then((m) => ({ default: m.MarkdownMessage })),
-)
+// 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.
@@ -112,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
@@ -245,11 +262,14 @@ function Bubble({
{mine ? (
body
) : (
- // Show the raw text until the renderer chunk arrives, so a message never
- // flashes blank.
- entirely, so the beacon never fires.
+ const html = render('before  after')
+ expect(html).not.toContain('
{
+ const html = render('| L | C | R |\n| :-- | :--: | --: |\n| a | b | c |')
+ expect(html).toContain('text-align:center')
+ expect(html).toContain('text-align:right')
+ })
})
diff --git a/web/src/editor/MarkdownMessage.tsx b/web/src/editor/MarkdownMessage.tsx
index a792ee8..d615863 100644
--- a/web/src/editor/MarkdownMessage.tsx
+++ b/web/src/editor/MarkdownMessage.tsx
@@ -1,13 +1,31 @@
+import { memo, type ReactNode } from 'react'
import ReactMarkdown, { type Components } from 'react-markdown'
import remarkGfm from 'remark-gfm'
-// The assistant answers in Markdown — including GFM tables — so render it as such
-// instead of showing the raw source. react-markdown does NOT emit raw HTML (no
-// rehype-raw), so an LLM reply can't inject markup; remark-gfm adds tables,
-// strikethrough and task lists. 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 inside its own box so
-// the bubble never blows out.
+// 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
+//
, 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 }) => (
+
{children}
, a: ({ href, children }) => ( @@ -21,26 +39,31 @@ const components: Components = { ), ul: ({ children }) =>{children}), hr: () =>
(styled
- // below); inline code is a bare and gets the pill treatment here.
- if (/language-/.test(className ?? '')) return {children}
+ // A fenced block is wrapped by (styled below) and carries either a
+ // language- class or a trailing newline; inline code is a single-line bare
+ // 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 {children}
return (
{children}
)
@@ -55,19 +78,32 @@ const components: Components = {
{children}
),
- th: ({ children }) => (
- {children}
+ // Pass `style` through: GFM column alignment (`:---:` / `---:`) arrives as
+ // style.textAlign, and dropping it would silently discard it.
+ th: ({ children, style }) => (
+
+ {children}
+
+ ),
+ td: ({ children, style }) => (
+
+ {children}
+
),
- td: ({ children }) => {children} ,
}
-/** Render one assistant message body as Markdown (GFM). */
-export function MarkdownMessage({ children }: { children: string }) {
+/** 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 (
-
+
{children}
)
-}
+})
diff --git a/web/src/lib/lazyPage.ts b/web/src/lib/lazyPage.ts
new file mode 100644
index 0000000..102536d
--- /dev/null
+++ b/web/src/lib/lazyPage.ts
@@ -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(load: () => Promise, 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 ? ComponentType : never
+ return lazy(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
+ }
+ })
+}
diff --git a/web/src/router.tsx b/web/src/router.tsx
index eb240c5..912ea6b 100644
--- a/web/src/router.tsx
+++ b/web/src/router.tsx
@@ -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(load: () => Promise, 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)