mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(web): preserve user prompt line breaks (#804)
* test: reproduce issue #794 * fix: preserve user prompt line breaks (closes #794)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }) => (
|
||||
<span data-testid="lazy-rainbow-text" data-inline={inline ? 'true' : 'false'}>{text}</span>
|
||||
LazyRainbowText: ({ text, inline, preserveSingleLineBreaks }: { text: string; inline?: boolean; preserveSingleLineBreaks?: boolean }) => (
|
||||
<span
|
||||
data-testid="lazy-rainbow-text"
|
||||
data-inline={inline ? 'true' : 'false'}
|
||||
data-preserve-single-line-breaks={preserveSingleLineBreaks ? 'true' : 'false'}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}))
|
||||
|
||||
@@ -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(<UserBubbleContent text={'Line one\nLine two\nLine three'} />)
|
||||
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')
|
||||
})
|
||||
|
||||
@@ -68,7 +68,7 @@ export function UserBubbleContent(props: { text: string }) {
|
||||
<div className="happy-chat-text min-w-0">
|
||||
<div className="inline-flex min-w-0 flex-wrap items-center gap-x-1 gap-y-1.5 align-top">
|
||||
{directives.map((directive) => <DirectiveChip key={directive} value={directive} />)}
|
||||
<LazyRainbowText text={body} inline />
|
||||
<LazyRainbowText text={body} inline preserveSingleLineBreaks />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -81,7 +81,7 @@ export function UserBubbleContent(props: { text: string }) {
|
||||
{directives.map((directive) => <DirectiveChip key={directive} value={directive} />)}
|
||||
</div>
|
||||
) : null}
|
||||
{hasBody ? <LazyRainbowText text={body} /> : null}
|
||||
{hasBody ? <LazyRainbowText text={body} preserveSingleLineBreaks /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<HTMLElement>(null)
|
||||
const [hasBeenVisible, setHasBeenVisible] = useState(false)
|
||||
@@ -148,6 +148,7 @@ export function LazyRainbowText(props: { text: string; inline?: boolean }) {
|
||||
<MarkdownRenderer
|
||||
content={text}
|
||||
className={props.inline ? 'inline' : undefined}
|
||||
preserveSingleLineBreaks={props.preserveSingleLineBreaks}
|
||||
components={
|
||||
hasSpecialWord && hasBeenVisible
|
||||
? rainbowComponents
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MarkdownTextPrimitive } from '@assistant-ui/react-markdown'
|
||||
import { TextMessagePartProvider } from '@assistant-ui/react'
|
||||
import {
|
||||
MARKDOWN_PLUGINS,
|
||||
MARKDOWN_PLUGINS_WITH_BREAKS,
|
||||
MARKDOWN_REHYPE_PLUGINS,
|
||||
MARKDOWN_COMPONENTS_BY_LANGUAGE,
|
||||
MARKDOWN_CLASSNAME,
|
||||
@@ -16,6 +17,7 @@ interface MarkdownRendererProps {
|
||||
content: string
|
||||
components?: MarkdownTextPrimitiveProps['components']
|
||||
className?: string
|
||||
preserveSingleLineBreaks?: boolean
|
||||
}
|
||||
|
||||
function MarkdownContent(props: MarkdownRendererProps) {
|
||||
@@ -27,7 +29,7 @@ function MarkdownContent(props: MarkdownRendererProps) {
|
||||
<UriConfirmProvider>
|
||||
<TextMessagePartProvider text={props.content}>
|
||||
<MarkdownTextPrimitive
|
||||
remarkPlugins={MARKDOWN_PLUGINS}
|
||||
remarkPlugins={props.preserveSingleLineBreaks ? MARKDOWN_PLUGINS_WITH_BREAKS : MARKDOWN_PLUGINS}
|
||||
rehypePlugins={MARKDOWN_REHYPE_PLUGINS}
|
||||
components={mergedComponents}
|
||||
componentsByLanguage={MARKDOWN_COMPONENTS_BY_LANGUAGE}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import remarkNonHttpsAutolink from '@/lib/remark-non-https-autolink'
|
||||
import remarkStripCjkAutolink from '@/lib/remark-strip-cjk-autolink'
|
||||
import { MARKDOWN_PLUGINS } from '@/components/assistant-ui/markdown-text'
|
||||
import { MARKDOWN_PLUGINS, MARKDOWN_PLUGINS_WITH_BREAKS } from '@/components/assistant-ui/markdown-text'
|
||||
|
||||
describe('MARKDOWN_PLUGINS integration', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<MarkdownTextPrimitiveProps['remarkPlugins']>
|
||||
|
||||
export const MARKDOWN_PLUGINS = [
|
||||
remarkGfm,
|
||||
...MARKDOWN_PLUGIN_TAIL,
|
||||
] satisfies NonNullable<MarkdownTextPrimitiveProps['remarkPlugins']>
|
||||
|
||||
// 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<MarkdownTextPrimitiveProps['remarkPlugins']>
|
||||
|
||||
export const MARKDOWN_REHYPE_PLUGINS = [rehypeKatex] satisfies NonNullable<MarkdownTextPrimitiveProps['rehypePlugins']>
|
||||
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 = {
|
||||
|
||||
Reference in New Issue
Block a user