Assistant answers render as markdown

react-markdown, lazy-loaded with the first answer (37 kB gzip chunk, main
bundle unchanged). No raw HTML, images dropped, links open in a new tab.
The system prompt asks for lists, inline code and fenced code blocks.
This commit is contained in:
2026-10-15 17:18:12 +02:00
parent 37f4ac597d
commit 8f8330c9e0
6 changed files with 237 additions and 6 deletions
+12 -3
View File
@@ -1,7 +1,7 @@
import { queryOptions, useQuery, useQueryClient } from "@tanstack/react-query"
import { useRouterState } from "@tanstack/react-router"
import { Bird, Bot, MessageCircle, Send, Square, Trash2 } from "lucide-react"
import { useEffect, useRef, useState } from "react"
import { lazy, Suspense, useEffect, useRef, useState } from "react"
import { AiService, type ChatItem } from "@/client"
import { flowKeys } from "@/components/Flow/queries"
@@ -23,6 +23,9 @@ import { type ChatTab, chatStore, useChat } from "./chatStore"
import { type ChatEvent, streamTurn } from "./stream"
import { type Action, type ControlItem, useControl } from "./useControl"
/** The renderer arrives with the first answer, not with the app. */
const Markdown = lazy(() => import("./Markdown"))
export const aiStatusQueryOptions = () =>
queryOptions({
queryKey: ["ai", "status"],
@@ -382,8 +385,14 @@ function Message({ item }: { item: Item }) {
)
case "assistant":
return (
<div className="self-start whitespace-pre-wrap break-words px-1 text-sm">
{item.text}
<div className="min-w-0 max-w-full self-start px-1 text-sm">
<Suspense
fallback={
<div className="whitespace-pre-wrap break-words">{item.text}</div>
}
>
<Markdown text={item.text} />
</Suspense>
</div>
)
case "error":
+57
View File
@@ -0,0 +1,57 @@
import ReactMarkdown, { type Components } from "react-markdown"
/**
* An assistant's answer as markdown, compact and on the panel's text size.
*
* Raw HTML is never rendered, and images are dropped so an answer cannot make
* the browser fetch a URL on its own. A link opens in a new tab when clicked.
*/
const components: Components = {
a: ({ node: _, ...props }) => (
<a
{...props}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
/>
),
ul: ({ node: _, ...props }) => (
<ul {...props} className="list-disc space-y-1 pl-5" />
),
ol: ({ node: _, ...props }) => (
<ol {...props} className="list-decimal space-y-1 pl-5" />
),
h1: ({ node: _, ...props }) => <h3 {...props} className="font-semibold" />,
h2: ({ node: _, ...props }) => <h3 {...props} className="font-semibold" />,
h3: ({ node: _, ...props }) => <h3 {...props} className="font-semibold" />,
h4: ({ node: _, ...props }) => <h4 {...props} className="font-medium" />,
blockquote: ({ node: _, ...props }) => (
<blockquote
{...props}
className="border-l-2 border-border pl-3 text-muted-foreground"
/>
),
hr: () => <hr className="border-border" />,
pre: ({ node: _, ...props }) => (
<pre
{...props}
className="overflow-x-auto rounded-md bg-muted p-2 font-mono text-xs [&>code]:bg-transparent [&>code]:p-0"
/>
),
code: ({ node: _, ...props }) => (
<code
{...props}
className="rounded-sm bg-muted px-1 py-0.5 font-mono text-xs"
/>
),
}
export default function Markdown({ text }: { text: string }) {
return (
<div className="min-w-0 space-y-2 break-words">
<ReactMarkdown components={components} disallowedElements={["img"]}>
{text}
</ReactMarkdown>
</div>
)
}