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 = {