diff --git a/bun.lock b/bun.lock
index 29aa6863..37db52c4 100644
--- a/bun.lock
+++ b/bun.lock
@@ -119,6 +119,7 @@
"react": "^19.2.3",
"react-dom": "^19.2.3",
"rehype-katex": "^7.0.1",
+ "remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shiki": "^3.20.0",
diff --git a/web/package.json b/web/package.json
index 735f38a0..f9cfc56e 100644
--- a/web/package.json
+++ b/web/package.json
@@ -38,6 +38,7 @@
"react": "^19.2.3",
"react-dom": "^19.2.3",
"rehype-katex": "^7.0.1",
+ "remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shiki": "^3.20.0",
diff --git a/web/src/components/AssistantChat/messages/user-bubble.test.tsx b/web/src/components/AssistantChat/messages/user-bubble.test.tsx
index 586172be..150e4f9e 100644
--- a/web/src/components/AssistantChat/messages/user-bubble.test.tsx
+++ b/web/src/components/AssistantChat/messages/user-bubble.test.tsx
@@ -2,8 +2,14 @@ import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
vi.mock('@/components/LazyRainbowText', () => ({
- LazyRainbowText: ({ text, inline }: { text: string; inline?: boolean }) => (
- {text}
+ LazyRainbowText: ({ text, inline, preserveSingleLineBreaks }: { text: string; inline?: boolean; preserveSingleLineBreaks?: boolean }) => (
+
+ {text}
+
)
}))
@@ -42,6 +48,13 @@ describe('UserBubbleContent', () => {
expect(screen.getByTestId('lazy-rainbow-text')).toHaveAttribute('data-inline', 'true')
})
+ it('asks LazyRainbowText to preserve single newlines in sent prompt bodies', () => {
+ const { container } = render()
+ const lazyText = container.querySelector('[data-testid="lazy-rainbow-text"]')
+
+ expect(lazyText).toHaveAttribute('data-preserve-single-line-breaks', 'true')
+ })
+
it('preserves original directive casing in chip labels', () => {
expect(formatDirectiveLabel('$DeEp-INTERVIEW')).toBe('DeEp INTERVIEW')
})
diff --git a/web/src/components/AssistantChat/messages/user-bubble.tsx b/web/src/components/AssistantChat/messages/user-bubble.tsx
index e0995598..be9a12db 100644
--- a/web/src/components/AssistantChat/messages/user-bubble.tsx
+++ b/web/src/components/AssistantChat/messages/user-bubble.tsx
@@ -68,7 +68,7 @@ export function UserBubbleContent(props: { text: string }) {
{directives.map((directive) => )}
-
+
)
@@ -81,7 +81,7 @@ export function UserBubbleContent(props: { text: string }) {
{directives.map((directive) => )}
) : null}
- {hasBody ? : null}
+ {hasBody ? : null}
)
}
diff --git a/web/src/components/LazyRainbowText.tsx b/web/src/components/LazyRainbowText.tsx
index b9d5d704..120f1915 100644
--- a/web/src/components/LazyRainbowText.tsx
+++ b/web/src/components/LazyRainbowText.tsx
@@ -102,7 +102,7 @@ function processChildrenForRainbow(children: React.ReactNode): React.ReactNode {
})
}
-export function LazyRainbowText(props: { text: string; inline?: boolean }) {
+export function LazyRainbowText(props: { text: string; inline?: boolean; preserveSingleLineBreaks?: boolean }) {
const text = props.text
const ref = useRef(null)
const [hasBeenVisible, setHasBeenVisible] = useState(false)
@@ -148,6 +148,7 @@ export function LazyRainbowText(props: { text: string; inline?: boolean }) {
{
it('includes remarkNonHttpsAutolink', () => {
@@ -14,4 +15,9 @@ describe('MARKDOWN_PLUGINS integration', () => {
expect(idxAutolink).toBeGreaterThan(0) // not first (remarkGfm is first)
expect(idxAutolink).toBeLessThan(idxCjk) // autolink before CJK strip
})
+
+ it('keeps hard-break parsing scoped to opt-in user prompt rendering', () => {
+ expect(MARKDOWN_PLUGINS).not.toContain(remarkBreaks)
+ expect(MARKDOWN_PLUGINS_WITH_BREAKS).toContain(remarkBreaks)
+ })
})
diff --git a/web/src/components/assistant-ui/markdown-text.tsx b/web/src/components/assistant-ui/markdown-text.tsx
index 925686cb..b07d6cc7 100644
--- a/web/src/components/assistant-ui/markdown-text.tsx
+++ b/web/src/components/assistant-ui/markdown-text.tsx
@@ -9,6 +9,7 @@ import {
type CodeHeaderProps,
} from '@assistant-ui/react-markdown'
import remarkGfm from 'remark-gfm'
+import remarkBreaks from 'remark-breaks'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import remarkDisableIndentedCode from '@/lib/remark-disable-indented-code'
@@ -33,8 +34,7 @@ import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown'
// from them. Both must come before remarkMath (to avoid treating TeX as URI).
// remarkFilePathLinks runs last to convert file paths → links after all other
// transforms have settled.
-export const MARKDOWN_PLUGINS = [
- remarkGfm,
+const MARKDOWN_PLUGIN_TAIL = [
remarkNonHttpsAutolink,
remarkStripCjkAutolink,
remarkMath,
@@ -42,6 +42,19 @@ export const MARKDOWN_PLUGINS = [
remarkFilePathLinks, // upstream — file path → link conversion, runs last
] satisfies NonNullable
+export const MARKDOWN_PLUGINS = [
+ remarkGfm,
+ ...MARKDOWN_PLUGIN_TAIL,
+] satisfies NonNullable
+
+// User-authored prompts should preserve Shift+Enter/newline intent without
+// changing assistant/tool markdown behavior globally.
+export const MARKDOWN_PLUGINS_WITH_BREAKS = [
+ remarkGfm,
+ remarkBreaks,
+ ...MARKDOWN_PLUGIN_TAIL,
+] satisfies NonNullable
+
export const MARKDOWN_REHYPE_PLUGINS = [rehypeKatex] satisfies NonNullable
export const MARKDOWN_CLASSNAME = 'aui-md happy-chat-text min-w-0 max-w-full break-words text-[var(--app-fg)]'
export const MARKDOWN_COMPONENTS_BY_LANGUAGE = {