Feat/agentflow Port Markdown Editor & Edit/Source Toggle from legacy UI to agentflow (#6125)

* Initial flow

* Fix markdown import shape

* Add support to check for HTML or markdown

* Fix values not being persisted

* Make implementation similar to v2

* Add vairable syntax support to expandDialog

* Add variable syntax support to messageInput

* Test cases added

* Remove eslint-disable

* Add getHtml as a fallback

* Fixed gemini comments

* Fix test cases and lint warnings

* Fixed editorUtils.test to not have nested structure

* Fix review comments
This commit is contained in:
j-sanaa
2026-04-02 15:15:17 -07:00
committed by GitHub
parent fedc6ebc82
commit 56f272a476
16 changed files with 716 additions and 144 deletions
+1
View File
@@ -73,6 +73,7 @@
"@codemirror/lang-python": "^6.1.0",
"@tabler/icons-react": "^3.7.0",
"@tiptap/core": "^3.20.4",
"@tiptap/markdown": "^3.20.4",
"@tiptap/extension-code-block-lowlight": "^3.20.4",
"@tiptap/extension-mention": "^3.20.4",
"@tiptap/extension-placeholder": "^3.20.4",
@@ -1,12 +1,25 @@
import { createElement, forwardRef } from 'react'
export const useEditor = (config?: Record<string, unknown>) => ({
getHTML: () => (config?.content as string) ?? '<p></p>',
getMarkdown: () => (config?.content as string) ?? '',
setEditable: jest.fn(),
commands: { focus: jest.fn(), setContent: jest.fn() },
_onUpdate: config?.onUpdate
})
export const useEditor = (config?: Record<string, unknown>) => {
// Track the current content so getMarkdown/getHTML reflect setContent() calls,
// mirroring real TipTap behaviour where setContent updates the editor state.
let currentContent: string = (config?.content as string) ?? ''
return {
getHTML: () => currentContent || '<p></p>',
getMarkdown: () => currentContent,
isEmpty: !currentContent,
setEditable: jest.fn(),
commands: {
focus: jest.fn(),
// Capture the first argument (the content string) so getMarkdown/getHTML
// return the value that was last loaded into the editor.
setContent: jest.fn((content: string) => {
currentContent = content
})
},
_onUpdate: config?.onUpdate
}
}
export const EditorContent = forwardRef<HTMLDivElement, { editor?: unknown; [k: string]: unknown }>(({ editor, ...rest }, ref) =>
createElement('div', { ref, 'data-testid': 'tiptap-editor-content', 'data-has-editor': !!editor, ...rest })
@@ -175,15 +175,20 @@ describe('ExpandTextDialog', () => {
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
})
it('should not show Edit/Source toggle in code mode', () => {
render(<ExpandTextDialog open={true} value='' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Source' })).not.toBeInTheDocument()
})
})
// --- Rich text mode ---
describe('inputType="string" (richtext)', () => {
it('should render the TipTap editor instead of a TextField', async () => {
render(
<ExpandTextDialog open={true} value='<p>Hello</p>' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
)
it('should render the TipTap editor instead of a TextField in Edit mode', async () => {
render(<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
// RichTextEditor renders data-testid='rich-text-editor' which wraps tiptap
expect(await screen.findByTestId('rich-text-editor')).toBeInTheDocument()
@@ -191,10 +196,11 @@ describe('ExpandTextDialog', () => {
// Plain TextField should NOT be present
expect(screen.queryByTestId('expand-content-input')).not.toBeInTheDocument()
expect(screen.queryByTestId('source-input')).not.toBeInTheDocument()
})
it('should render plain TextField for non-string, non-code input types', () => {
render(<ExpandTextDialog open={true} value='Hello' inputType='number' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
render(<ExpandTextDialog open={true} value='42' inputType='number' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
expect(screen.getByTestId('expand-content-input')).toBeInTheDocument()
expect(screen.queryByTestId('rich-text-editor')).not.toBeInTheDocument()
@@ -202,9 +208,7 @@ describe('ExpandTextDialog', () => {
})
it('should still show Save and Cancel buttons in richtext mode', () => {
render(
<ExpandTextDialog open={true} value='<p>Hello</p>' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
)
render(<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
@@ -214,7 +218,7 @@ describe('ExpandTextDialog', () => {
render(
<ExpandTextDialog
open={true}
value='<p>Hello</p>'
value='Hello'
inputType='string'
disabled={true}
onConfirm={mockOnConfirm}
@@ -241,14 +245,204 @@ describe('ExpandTextDialog', () => {
})
it('should call onCancel when Cancel is clicked in richtext mode', () => {
render(
<ExpandTextDialog open={true} value='<p>Hello</p>' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
)
render(<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(mockOnCancel).toHaveBeenCalled()
expect(mockOnConfirm).not.toHaveBeenCalled()
})
// --- Edit/Source toggle ---
it('should show Edit and Source toggle buttons in richtext mode', () => {
render(<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Source' })).toBeInTheDocument()
})
it('should not show Edit/Source toggle for non-string input types', () => {
render(<ExpandTextDialog open={true} value='42' inputType='number' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Source' })).not.toBeInTheDocument()
})
it('should not change mode when the active toggle button is clicked again', () => {
render(
<ExpandTextDialog open={true} value='## My heading' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
)
// Already in Edit mode — clicking Edit again should keep the rich-text editor visible
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument()
expect(screen.queryByTestId('source-input')).not.toBeInTheDocument()
})
it('should switch to Source mode showing a raw text field when Source is clicked', () => {
render(
<ExpandTextDialog open={true} value='## My heading' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
)
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
// Source mode renders a monospace TextField with data-testid='source-input'
expect(screen.getByTestId('source-input')).toBeInTheDocument()
// TipTap editor should be unmounted
expect(screen.queryByTestId('rich-text-editor')).not.toBeInTheDocument()
})
it('should show the current markdown value in Source mode', () => {
render(
<ExpandTextDialog open={true} value='## My heading' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
)
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
const sourceTextarea = screen.getByTestId('source-input').querySelector('textarea')!
expect(sourceTextarea).toHaveValue('## My heading')
})
it('should switch back to Edit mode when Edit is clicked from Source', () => {
render(<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
expect(screen.getByTestId('source-input')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument()
expect(screen.queryByTestId('source-input')).not.toBeInTheDocument()
})
it('should save edits made in Source mode when Save is clicked', () => {
render(
<ExpandTextDialog open={true} value='## My heading' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
)
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
const sourceTextarea = screen.getByTestId('source-input').querySelector('textarea')!
fireEvent.change(sourceTextarea, { target: { value: '## Updated heading' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(mockOnConfirm).toHaveBeenCalledWith('## Updated heading')
})
it('should disable the toggle buttons when disabled', () => {
render(
<ExpandTextDialog
open={true}
value='Hello'
inputType='string'
disabled={true}
onConfirm={mockOnConfirm}
onCancel={mockOnCancel}
/>
)
expect(screen.getByRole('button', { name: 'Edit' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Source' })).toBeDisabled()
})
it('should reset to Edit mode when the dialog is reopened', () => {
const { rerender } = render(
<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
)
// Switch to Source mode
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
expect(screen.getByTestId('source-input')).toBeInTheDocument()
// Close dialog
rerender(<ExpandTextDialog open={false} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
// Reopen — should be back in Edit mode
rerender(<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument()
expect(screen.queryByTestId('source-input')).not.toBeInTheDocument()
})
})
// --- With suggestionItems (VariableInput mode) ---
describe('inputType="string" with suggestionItems', () => {
const mockSuggestionItems = [{ id: 'question', label: 'question', description: "User's question", category: 'Chat Context' }]
it('should render VariableInput instead of RichTextEditor in Edit mode when suggestionItems provided', () => {
render(
<ExpandTextDialog
open={true}
value='Hello'
inputType='string'
suggestionItems={mockSuggestionItems}
onConfirm={mockOnConfirm}
onCancel={mockOnCancel}
/>
)
expect(screen.getByTestId('variable-input')).toBeInTheDocument()
expect(screen.queryByTestId('rich-text-editor')).not.toBeInTheDocument()
})
it('should switch to Source mode and back to VariableInput when suggestionItems provided', () => {
render(
<ExpandTextDialog
open={true}
value='Hello {{question}}'
inputType='string'
suggestionItems={mockSuggestionItems}
onConfirm={mockOnConfirm}
onCancel={mockOnCancel}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
expect(screen.getByTestId('source-input')).toBeInTheDocument()
expect(screen.queryByTestId('variable-input')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
expect(screen.getByTestId('variable-input')).toBeInTheDocument()
expect(screen.queryByTestId('source-input')).not.toBeInTheDocument()
})
it('should still show Edit/Source toggle when suggestionItems provided', () => {
render(
<ExpandTextDialog
open={true}
value=''
inputType='string'
suggestionItems={mockSuggestionItems}
onConfirm={mockOnConfirm}
onCancel={mockOnCancel}
/>
)
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Source' })).toBeInTheDocument()
})
it('should save edits made in Source mode when suggestionItems provided', () => {
render(
<ExpandTextDialog
open={true}
value='Hello {{question}}'
inputType='string'
suggestionItems={mockSuggestionItems}
onConfirm={mockOnConfirm}
onCancel={mockOnCancel}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
const sourceTextarea = screen.getByTestId('source-input').querySelector('textarea')!
fireEvent.change(sourceTextarea, { target: { value: 'Updated {{question}}' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(mockOnConfirm).toHaveBeenCalledWith('Updated {{question}}')
})
})
})
@@ -1,10 +1,14 @@
import { useCallback, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { Box, Button, Dialog, DialogActions, DialogContent, TextField, Typography } from '@mui/material'
import { Box, Button, Dialog, DialogActions, DialogContent, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
import { IconCode, IconPencil } from '@tabler/icons-react'
import type { Editor } from '@tiptap/react'
import { getEditorMarkdown } from '@/atoms/utils/'
import { CodeInput } from './CodeInput'
import { RichTextEditor } from './RichTextEditor.lazy'
import type { SuggestionItem } from './SuggestionDropdown'
import type { SuggestionItem } from './VariableInput'
import { VariableInput } from './VariableInput'
export interface ExpandTextDialogProps {
@@ -23,9 +27,14 @@ export interface ExpandTextDialogProps {
onCancel: () => void
}
type EditorMode = 'edit' | 'source'
/**
* A reusable expand dialog for editing long text content in a larger viewport.
* Used by NodeInputHandler (multiline string fields) and MessagesInput (message content).
*
* For `inputType='string'`, an Edit/Source toggle lets users switch between the
* WYSIWYG TipTap editor and a raw markdown text view.
*/
export function ExpandTextDialog({
open,
@@ -41,12 +50,15 @@ export function ExpandTextDialog({
}: ExpandTextDialogProps) {
const [localValue, setLocalValue] = useState(value)
const [prevOpen, setPrevOpen] = useState(open)
const [mode, setMode] = useState<EditorMode>('edit')
const editorRef = useRef<Editor | null>(null)
// Sync localValue synchronously when the dialog opens so the TipTap editor
// initialises with the correct content (useEffect would leave a one-render
// Sync localValue and reset mode synchronously when the dialog opens so the TipTap
// editor initialises with the correct content (useEffect would leave a one-render
// gap where localValue is stale, causing the editor to show empty/old text).
if (open && !prevOpen) {
setLocalValue(value)
setMode('edit')
setPrevOpen(true)
} else if (!open && prevOpen) {
setPrevOpen(false)
@@ -56,13 +68,49 @@ export function ExpandTextDialog({
onConfirm(localValue)
}, [localValue, onConfirm])
const handleModeChange = (_: React.MouseEvent<HTMLElement>, newMode: EditorMode | null) => {
if (!newMode || newMode === mode) return
// When switching to Source, flush the editor's current state to markdown so the
// textarea shows markdown rather than a raw HTML string (Gap 3 fix — mirrors PR #6021).
if (newMode === 'source' && editorRef.current) {
setLocalValue(getEditorMarkdown(editorRef.current))
}
setMode(newMode)
}
const isRichText = inputType === 'string'
return (
<Dialog open={open} fullWidth maxWidth='md'>
<DialogContent>
{title && (
<Typography variant='h4' sx={{ mb: '10px' }}>
{title}
</Typography>
{(title || isRichText) && (
<Box sx={{ display: 'flex', alignItems: 'center', mb: '10px' }}>
{title && (
<Typography variant='h4' sx={{ flex: 1 }}>
{title}
</Typography>
)}
{!title && <Box sx={{ flex: 1 }} />}
{isRichText && (
<ToggleButtonGroup
value={mode}
exclusive
onChange={handleModeChange}
size='small'
disabled={disabled}
aria-label='editor mode'
>
<ToggleButton value='edit' aria-label='Edit'>
<IconPencil size='1rem' stroke={1.5} style={{ marginRight: 4 }} />
Edit
</ToggleButton>
<ToggleButton value='source' aria-label='Source'>
<IconCode size='1rem' stroke={1.5} style={{ marginRight: 4 }} />
Source
</ToggleButton>
</ToggleButtonGroup>
)}
</Box>
)}
{inputType === 'code' ? (
<CodeInput
@@ -72,26 +120,41 @@ export function ExpandTextDialog({
disabled={disabled}
height='calc(100vh - 220px)'
/>
) : inputType === 'string' ? (
<Box
sx={{
borderRadius: '12px',
maxHeight: 'calc(100vh - 220px)',
overflowY: 'auto',
overflowX: 'hidden'
}}
>
{suggestionItems && suggestionItems.length > 0 ? (
<VariableInput
value={localValue}
onChange={setLocalValue}
placeholder={placeholder}
disabled={disabled}
rows={15}
suggestionItems={suggestionItems}
autoFocus
/>
) : (
) : isRichText ? (
mode === 'source' ? (
<TextField
fullWidth
multiline
minRows={15}
value={localValue}
disabled={disabled}
onChange={(e) => setLocalValue(e.target.value)}
placeholder={placeholder}
data-testid='source-input'
inputProps={{ style: { fontFamily: 'monospace', fontSize: '0.85em' } }}
/>
) : suggestionItems?.length ? (
<VariableInput
value={localValue}
onChange={setLocalValue}
placeholder={placeholder}
disabled={disabled}
rows={15}
autoFocus
suggestionItems={suggestionItems}
onEditorReady={(editor) => {
editorRef.current = editor
}}
/>
) : (
<Box
sx={{
borderRadius: '12px',
maxHeight: 'calc(100vh - 220px)',
overflowY: 'auto',
overflowX: 'hidden'
}}
>
<RichTextEditor
value={localValue}
onChange={setLocalValue}
@@ -99,9 +162,12 @@ export function ExpandTextDialog({
disabled={disabled}
rows={15}
autoFocus
onEditorReady={(editor) => {
editorRef.current = editor
}}
/>
)}
</Box>
</Box>
)
) : (
<TextField
fullWidth
@@ -9,13 +9,15 @@ const mockOnDataChange = jest.fn()
jest.mock('@tabler/icons-react', () => ({
IconArrowsMaximize: () => <span data-testid='icon-expand' />,
IconCode: () => <span />,
IconPencil: () => <span />,
IconPlus: () => <span data-testid='icon-plus' />,
IconTrash: () => <span data-testid='icon-trash' />,
IconVariable: () => <span data-testid='icon-variable' />
}))
// Replace the TipTap-based RichTextEditor with a plain textarea so tests can
// simulate content changes via fireEvent.change without TipTap internals.
// Replace the TipTap-based RichTextEditor with a plain textarea so tests that open the
// expand dialog (without variableItems) can verify dialog content via fireEvent.change.
jest.mock('./RichTextEditor', () => ({
RichTextEditor: ({
value,
@@ -40,6 +42,8 @@ jest.mock('./RichTextEditor', () => ({
)
}))
// Replace the TipTap-based VariableInput with a plain textarea so tests can
// simulate content changes via fireEvent.change without TipTap internals.
jest.mock('./VariableInput', () => ({
VariableInput: ({
value,
@@ -48,9 +52,10 @@ jest.mock('./VariableInput', () => ({
placeholder
}: {
value: string
onChange: (html: string) => void
onChange: (v: string) => void
disabled?: boolean
placeholder?: string
[k: string]: unknown
}) => (
<div data-testid='variable-input'>
<textarea
@@ -123,9 +128,9 @@ describe('MessagesInput', () => {
const roleSelects = screen.getAllByRole('combobox')
expect(roleSelects).toHaveLength(2)
// Content fields rendered as rich text editors (TipTap) — lazy-loaded
// Content fields rendered as VariableInput (TipTap-based)
await waitFor(() => {
expect(screen.getAllByTestId('rich-text-editor')).toHaveLength(2)
expect(screen.getAllByTestId('variable-input')).toHaveLength(2)
})
})
@@ -214,7 +219,7 @@ describe('MessagesInput', () => {
// --- Content field ---
it('should render rich text editor for content field', async () => {
it('should render VariableInput for content field', async () => {
const dataWithMessages: NodeData = {
...mockNodeData,
inputs: {
@@ -224,13 +229,13 @@ describe('MessagesInput', () => {
render(<MessagesInput inputParam={mockInputParam} data={dataWithMessages} onDataChange={mockOnDataChange} />)
expect(await screen.findByTestId('rich-text-editor')).toBeInTheDocument()
expect(screen.getByTestId('tiptap-editor-content')).toBeInTheDocument()
expect(await screen.findByTestId('variable-input')).toBeInTheDocument()
expect(screen.getByTestId('variable-input-content')).toBeInTheDocument()
})
// --- Content change ---
it('should update content when RichTextEditor fires onChange', async () => {
it('should update content when VariableInput fires onChange', async () => {
const dataWithMessages: NodeData = {
...mockNodeData,
inputs: {
@@ -240,7 +245,7 @@ describe('MessagesInput', () => {
render(<MessagesInput inputParam={mockInputParam} data={dataWithMessages} onDataChange={mockOnDataChange} />)
const textarea = await screen.findByTestId('tiptap-editor-content')
const textarea = await screen.findByTestId('variable-input-content')
fireEvent.change(textarea, { target: { value: 'Updated content' } })
expect(mockOnDataChange).toHaveBeenCalledWith({
@@ -249,7 +254,7 @@ describe('MessagesInput', () => {
})
})
it('should support variable syntax in content via RichTextEditor', async () => {
it('should support variable syntax in content via VariableInput', async () => {
const dataWithMessages: NodeData = {
...mockNodeData,
inputs: {
@@ -259,7 +264,7 @@ describe('MessagesInput', () => {
render(<MessagesInput inputParam={mockInputParam} data={dataWithMessages} onDataChange={mockOnDataChange} />)
const textarea = await screen.findByTestId('tiptap-editor-content')
const textarea = await screen.findByTestId('variable-input-content')
fireEvent.change(textarea, { target: { value: '{{ question }}' } })
expect(mockOnDataChange).toHaveBeenCalledWith({
@@ -281,21 +286,20 @@ describe('MessagesInput', () => {
render(<MessagesInput inputParam={mockInputParam} data={dataWithMessages} onDataChange={mockOnDataChange} />)
// Edit inline content (parent has NOT re-rendered with new data yet)
const textarea = await screen.findByTestId('tiptap-editor-content')
const textarea = await screen.findByTestId('variable-input-content')
fireEvent.change(textarea, { target: { value: 'Edited inline' } })
// Open expand dialog
fireEvent.click(screen.getByTitle('Expand'))
// Wait for the lazy-loaded RichTextEditor inside the expand dialog to mount
// Wait for the RichTextEditor inside the expand dialog to mount (no suggestionItems)
await waitFor(() => {
expect(screen.getAllByTestId('tiptap-editor-content')).toHaveLength(2)
expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument()
})
// The expand dialog should show the edited value from latestContentRef,
// not the stale 'Original' from messages prop
const editors = screen.getAllByTestId('tiptap-editor-content')
const expandTextarea = editors[editors.length - 1] as HTMLTextAreaElement
const expandTextarea = screen.getByTestId('tiptap-editor-content') as HTMLTextAreaElement
expect(expandTextarea.value).toBe('Edited inline')
})
@@ -314,7 +318,7 @@ describe('MessagesInput', () => {
const { rerender } = render(<MessagesInput inputParam={mockInputParam} data={dataWithMessages} onDataChange={mockOnDataChange} />)
// Edit the third message (index 2) inline
const textareas = await screen.findAllByTestId('tiptap-editor-content')
const textareas = await screen.findAllByTestId('variable-input-content')
fireEvent.change(textareas[2], { target: { value: 'Edited assistant reply' } })
// Delete the first message (index 0) — this should shift index 2 → 1 in latestContentRef
@@ -338,8 +342,7 @@ describe('MessagesInput', () => {
fireEvent.click(expandButtons[1])
// The expand dialog should show the edited content from the shifted ref
const editors = screen.getAllByTestId('tiptap-editor-content')
const expandTextarea = editors[editors.length - 1] as HTMLTextAreaElement
const expandTextarea = screen.getByTestId('tiptap-editor-content') as HTMLTextAreaElement
expect(expandTextarea.value).toBe('Edited assistant reply')
})
@@ -361,8 +364,8 @@ describe('MessagesInput', () => {
// Delete button disabled
expect(screen.getByTitle('Delete')).toBeDisabled()
// Rich text editor is rendered (disabled state is handled by TipTap internally)
expect(await screen.findByTestId('rich-text-editor')).toBeInTheDocument()
// VariableInput is rendered (disabled state is handled by TipTap internally)
expect(await screen.findByTestId('variable-input')).toBeInTheDocument()
})
// --- minItems constraint ---
@@ -451,7 +454,7 @@ describe('MessagesInput', () => {
// --- Expand dialog (now uses rich text mode with TipTap) ---
it('should open expand dialog with rich text editor when expand icon is clicked', async () => {
it('should open expand dialog with rich text editor when expand icon is clicked (no variableItems)', async () => {
const dataWithMessages: NodeData = {
...mockNodeData,
inputs: {
@@ -463,14 +466,42 @@ describe('MessagesInput', () => {
fireEvent.click(screen.getByTitle('Expand'))
// Inline editor + expand dialog editor = 2 rich text editors
// Inline uses VariableInput; dialog (no suggestionItems) falls back to RichTextEditor
await waitFor(() => {
expect(screen.getAllByTestId('rich-text-editor')).toHaveLength(2)
expect(screen.getByTestId('variable-input')).toBeInTheDocument()
expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument()
})
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
})
it('should open expand dialog with VariableInput when variableItems are provided', async () => {
const dataWithMessages: NodeData = {
...mockNodeData,
inputs: {
agentMessages: [{ role: 'user', content: 'Hello {{question}}' }]
}
} as NodeData
const variableItems = [{ value: '{{question}}', label: 'question', description: "User's question", category: 'Chat Context' }]
render(
<MessagesInput
inputParam={mockInputParam}
data={dataWithMessages}
variableItems={variableItems}
onDataChange={mockOnDataChange}
/>
)
fireEvent.click(screen.getByTitle('Expand'))
// Both inline and expand dialog use VariableInput when suggestionItems are available
await waitFor(() => {
expect(screen.getAllByTestId('variable-input')).toHaveLength(2)
})
expect(screen.queryByTestId('rich-text-editor')).not.toBeInTheDocument()
})
it('should call onConfirm with current value when Save is clicked in expand dialog', () => {
const dataWithMessages: NodeData = {
...mockNodeData,
@@ -556,7 +587,7 @@ describe('MessagesInput', () => {
expect(screen.queryByTestId('rich-text-editor')).not.toBeInTheDocument()
})
it('should fall back to RichTextEditor when variableItems is empty', async () => {
it('should render VariableInput even when variableItems is empty', async () => {
const dataWithMessages: NodeData = {
...mockNodeData,
inputs: {
@@ -566,8 +597,8 @@ describe('MessagesInput', () => {
render(<MessagesInput inputParam={mockInputParam} data={dataWithMessages} onDataChange={mockOnDataChange} variableItems={[]} />)
expect(await screen.findByTestId('rich-text-editor')).toBeInTheDocument()
expect(screen.queryByTestId('variable-input')).not.toBeInTheDocument()
expect(await screen.findByTestId('variable-input')).toBeInTheDocument()
expect(screen.queryByTestId('rich-text-editor')).not.toBeInTheDocument()
})
it('should update content via VariableInput when variableItems are provided', async () => {
+11 -22
View File
@@ -7,7 +7,6 @@ import { IconArrowsMaximize, IconPlus, IconTrash, IconVariable } from '@tabler/i
import type { InputParam, NodeData } from '@/core/types'
import { ExpandTextDialog } from './ExpandTextDialog'
import { RichTextEditor } from './RichTextEditor.lazy'
import { toSuggestionItems } from './toSuggestionItems'
import { useStableKeys } from './useStableKeys'
import { VariableInput } from './VariableInput'
@@ -31,9 +30,9 @@ export interface MessagesInputProps {
inputParam: InputParam
data: NodeData
disabled?: boolean
onDataChange?: (params: { inputParam: InputParam; newValue: unknown }) => void
/** Variable items for {{ autocomplete in message content fields. */
/** Variable items for `{{ }}` autocomplete in message content fields. */
variableItems?: VariableItem[]
onDataChange?: (params: { inputParam: InputParam; newValue: unknown }) => void
}
/**
@@ -41,7 +40,7 @@ export interface MessagesInputProps {
* Each entry has a role dropdown (system/assistant/developer/user)
* and a multiline content textarea with variable support ({{ variable }} syntax).
*/
export function MessagesInput({ inputParam, data, disabled = false, onDataChange, variableItems }: MessagesInputProps) {
export function MessagesInput({ inputParam, data, disabled = false, variableItems, onDataChange }: MessagesInputProps) {
const theme = useTheme()
const messages = useMemo(
@@ -207,24 +206,14 @@ export function MessagesInput({ inputParam, data, disabled = false, onDataChange
<IconArrowsMaximize />
</IconButton>
</div>
{suggestionItems && suggestionItems.length > 0 ? (
<VariableInput
value={message.content}
onChange={(html) => handleContentChange(index, html)}
placeholder='Message content (supports {{ variable }} syntax)'
disabled={disabled}
rows={4}
suggestionItems={suggestionItems}
/>
) : (
<RichTextEditor
value={message.content}
onChange={(html) => handleContentChange(index, html)}
placeholder='Message content (supports {{ variable }} syntax)'
disabled={disabled}
rows={4}
/>
)}
<VariableInput
value={message.content}
onChange={(v) => handleContentChange(index, v)}
placeholder='Message content (supports {{ variable }} syntax)'
disabled={disabled}
rows={4}
suggestionItems={suggestionItems}
/>
</Box>
</Box>
))}
@@ -44,7 +44,9 @@ jest.mock('./VariableInput', () => ({
jest.mock('@tabler/icons-react', () => ({
IconArrowsMaximize: () => <span data-testid='icon-expand' />,
IconCode: () => <span />,
IconInfoCircle: () => <span data-testid='icon-info-circle' />,
IconPencil: () => <span />,
IconVariable: () => <span data-testid='icon-variable' />,
IconRefresh: () => <span data-testid='icon-refresh' />
}))
@@ -3,7 +3,9 @@ import { render, screen } from '@testing-library/react'
import { RichTextEditor } from './RichTextEditor'
// --- Mock TipTap ---
let capturedOnUpdate: ((args: { editor: { getHTML: () => string } }) => void) | undefined
// editor.getMarkdown() is added directly on the Editor interface by @tiptap/markdown
// via module augmentation — it is NOT nested under storage.markdown.
let capturedOnUpdate: ((args: { editor: { getMarkdown: () => string } }) => void) | undefined
jest.mock('@tiptap/react', () => ({
useEditor: (config: Record<string, unknown>) => {
@@ -12,7 +14,7 @@ jest.mock('@tiptap/react', () => ({
return {
setEditable: jest.fn(),
commands: { focus: jest.fn(), setContent: jest.fn() },
getHTML: () => '<p>mock</p>'
getMarkdown: () => 'mock markdown'
}
},
EditorContent: ({ editor, ...rest }: { editor: unknown; [key: string]: unknown }) => (
@@ -38,6 +40,10 @@ jest.mock('@tiptap/extension-placeholder', () => ({
default: { configure: jest.fn(() => 'Placeholder') }
}))
jest.mock('@tiptap/markdown', () => ({
Markdown: { configure: jest.fn(() => 'Markdown') }
}))
jest.mock('lowlight', () => ({
common: {},
createLowlight: jest.fn(() => ({ register: jest.fn() }))
@@ -52,26 +58,35 @@ beforeEach(() => {
describe('RichTextEditor', () => {
it('should render the editor container', () => {
render(<RichTextEditor value='<p>Hello</p>' onChange={mockOnChange} />)
render(<RichTextEditor value='Hello' onChange={mockOnChange} />)
expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument()
expect(screen.getByTestId('tiptap-editor-content')).toBeInTheDocument()
})
it('should pass the editor instance to EditorContent', () => {
render(<RichTextEditor value='<p>Hello</p>' onChange={mockOnChange} />)
render(<RichTextEditor value='Hello' onChange={mockOnChange} />)
expect(screen.getByTestId('tiptap-editor-content')).toHaveAttribute('data-has-editor', 'true')
})
it('should call onChange when editor content updates', () => {
it('should call onChange with markdown when editor content updates', () => {
render(<RichTextEditor value='' onChange={mockOnChange} />)
// Simulate TipTap onUpdate callback
expect(capturedOnUpdate).toBeDefined()
capturedOnUpdate!({ editor: { getHTML: () => '<p>Updated</p>' } })
capturedOnUpdate!({ editor: { getMarkdown: () => '**Updated**' } })
expect(mockOnChange).toHaveBeenCalledWith('<p>Updated</p>')
expect(mockOnChange).toHaveBeenCalledWith('**Updated**')
})
it('should call onChange with Markdown', () => {
render(<RichTextEditor value='' onChange={mockOnChange} />)
capturedOnUpdate!({ editor: { getMarkdown: () => '## Heading' } })
// Must be the markdown string, not wrapped in HTML tags
expect(mockOnChange).toHaveBeenCalledWith('## Heading')
expect(mockOnChange).not.toHaveBeenCalledWith(expect.stringContaining('<h2>'))
})
it('should render with rows prop', () => {
+61 -11
View File
@@ -4,6 +4,8 @@ import { Box } from '@mui/material'
import { styled } from '@mui/material/styles'
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import Placeholder from '@tiptap/extension-placeholder'
import { Markdown } from '@tiptap/markdown'
import type { Editor } from '@tiptap/react'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
// Individual language imports instead of lowlight/common to tree-shake highlight.js
@@ -14,6 +16,7 @@ import python from 'highlight.js/lib/languages/python'
import typescript from 'highlight.js/lib/languages/typescript'
import { createLowlight } from 'lowlight'
import { getEditorMarkdown, isHtmlContent } from '@/atoms/utils/'
import { tokens } from '@/core/theme/tokens'
const lowlight = createLowlight()
@@ -23,21 +26,26 @@ lowlight.register('python', python)
lowlight.register('typescript', typescript)
export interface RichTextEditorProps {
/** HTML content */
/** Markdown string, or legacy HTML string for backward compatibility */
value: string
/** Called with the updated HTML string on every edit */
onChange: (html: string) => void
/** Called with the updated markdown string on every edit */
onChange: (markdown: string) => void
placeholder?: string
disabled?: boolean
/** Number of visible text rows (controls editor height) */
rows?: number
/** Auto-focus when the editor mounts */
autoFocus?: boolean
/** Called with the live editor instance once it is ready (and null on unmount). Used by ExpandTextDialog to call getMarkdown() on mode switch. */
onEditorReady?: (editor: Editor | null) => void
/** When true, emits markdown; when false, emits HTML. Defaults to true. */
useMarkdown?: boolean
}
/* ── TipTap extensions (no mention/variable support — that belongs in features/) ── */
const buildExtensions = (placeholder?: string) => [
Markdown,
StarterKit.configure({ codeBlock: false }),
CodeBlockLowlight.configure({ lowlight, enableTabIndentation: true, tabSize: 2 }),
...(placeholder ? [Placeholder.configure({ placeholder })] : [])
@@ -134,34 +142,76 @@ const StyledEditorContent = styled(EditorContent, {
/**
* A TipTap-based rich text editor atom with code block syntax highlighting.
*
* Stores and emits content as markdown. Legacy HTML values (content starting with `<`)
* are accepted for backward compatibility — TipTap renders them correctly, and
* subsequent edits will emit markdown.
*
* This is a "dumb" UI primitive — it receives all data via props and owns no
* business logic. Variable/mention support lives in the features layer.
*/
export function RichTextEditor({ value, onChange, placeholder, disabled = false, rows, autoFocus = false }: RichTextEditorProps) {
export function RichTextEditor({
value,
onChange,
placeholder,
disabled = false,
rows,
autoFocus = false,
onEditorReady,
useMarkdown = true
}: RichTextEditorProps) {
// Keep a ref to the latest onChange so the TipTap onUpdate callback never goes stale
const onChangeRef = useRef(onChange)
useEffect(() => {
onChangeRef.current = onChange
}, [onChange])
// Track the last value we emitted (or loaded) to avoid re-setting content
// when the parent echoes our own onChange value back as the new prop.
const lastEmittedRef = useRef<string>(value || '')
// Capture initial value and useMarkdown for the mount effect below so we can
// depend only on `editor` without suppressing the exhaustive-deps rule.
const initialValueRef = useRef(value)
const useMarkdownRef = useRef(useMarkdown)
const extensions = useMemo(() => buildExtensions(placeholder), [placeholder])
const editor = useEditor({
extensions,
content: value,
content: '',
editable: !disabled,
autofocus: autoFocus ? 'end' : false,
onUpdate: ({ editor: ed }: { editor: { getHTML: () => string } }) => {
onChangeRef.current(ed.getHTML())
onUpdate: ({ editor: ed }) => {
const value = useMarkdown ? getEditorMarkdown(ed) : ed.getHTML()
lastEmittedRef.current = value
onChangeRef.current(value)
}
})
// Sync external value changes into the editor (e.g. when parent state updates)
// Notify parent when the editor instance is ready (used by ExpandTextDialog to flush
// the current editor state to markdown when switching to Source mode).
useEffect(() => {
if (editor && value !== editor.getHTML()) {
editor.commands.setContent(value, { emitUpdate: false })
onEditorReady?.(editor)
return () => onEditorReady?.(null)
}, [editor, onEditorReady])
// Load initial content once the editor is ready, detecting legacy HTML vs markdown.
// Reads from refs so only `editor` needs to be in the dep array.
useEffect(() => {
if (!editor || !initialValueRef.current) return
const contentType = !useMarkdownRef.current || isHtmlContent(initialValueRef.current) ? 'html' : 'markdown'
editor.commands.setContent(initialValueRef.current, { emitUpdate: false, contentType })
lastEmittedRef.current = initialValueRef.current
}, [editor])
// Sync genuine external value changes (e.g. parent resets the field programmatically).
useEffect(() => {
if (editor && value !== lastEmittedRef.current) {
const contentType = !useMarkdown || isHtmlContent(value) ? 'html' : 'markdown'
editor.commands.setContent(value, { emitUpdate: false, contentType })
lastEmittedRef.current = value
}
}, [editor, value])
}, [editor, value, useMarkdown])
// Sync editable state when disabled prop changes
useEffect(() => {
@@ -69,19 +69,33 @@ describe('VariableInput', () => {
expect(screen.getByTestId('variable-input')).toBeInTheDocument()
})
it('initialises the editor with the provided value', () => {
// Verify the editor is created with content matching the value prop, so
// the editor is not empty on the first render before the sync useEffect fires.
it('always initialises the editor with empty content (value is loaded via setContent effect)', () => {
// The editor always starts empty; value is loaded once via editor.commands.setContent
// after the editor instance is ready — this avoids content flicker on re-renders.
const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor')
renderVariableInput({ value: 'Hello world' })
expect(useEditorSpy).toHaveBeenCalledWith(expect.objectContaining({ content: 'Hello world' }))
useEditorSpy.mockRestore()
})
it('initialises the editor with empty string when value is empty', () => {
const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor')
renderVariableInput({ value: '' })
expect(useEditorSpy).toHaveBeenCalledWith(expect.objectContaining({ content: '' }))
useEditorSpy.mockRestore()
})
it('passes autofocus:"end" to useEditor when autoFocus is true', () => {
const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor')
renderVariableInput({ autoFocus: true })
expect(useEditorSpy).toHaveBeenCalledWith(expect.objectContaining({ autofocus: 'end' }))
useEditorSpy.mockRestore()
})
it('passes autofocus:false to useEditor when autoFocus is false (default)', () => {
const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor')
renderVariableInput({ autoFocus: false })
expect(useEditorSpy).toHaveBeenCalledWith(expect.objectContaining({ autofocus: false }))
useEditorSpy.mockRestore()
})
it('calls onEditorReady with the editor instance after mount', () => {
const onEditorReady = jest.fn()
renderVariableInput({ onEditorReady })
// The mock editor has getHTML/getMarkdown/commands etc.
expect(onEditorReady).toHaveBeenCalledWith(expect.objectContaining({ getHTML: expect.any(Function) }))
})
})
+57 -14
View File
@@ -5,6 +5,8 @@ import { styled } from '@mui/material/styles'
import { mergeAttributes } from '@tiptap/core'
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import Placeholder from '@tiptap/extension-placeholder'
import { Markdown } from '@tiptap/markdown'
import type { Editor } from '@tiptap/react'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import javascript from 'highlight.js/lib/languages/javascript'
@@ -13,6 +15,7 @@ import python from 'highlight.js/lib/languages/python'
import typescript from 'highlight.js/lib/languages/typescript'
import { createLowlight } from 'lowlight'
import { getEditorMarkdown, isHtmlContent } from '@/atoms/utils/'
import { CustomMention } from '@/core/primitives/customMention'
import { tokens } from '@/core/theme/tokens'
@@ -32,12 +35,14 @@ export interface VariableInputProps {
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
/** Number of visible text rows. When set, renders as a multiline editor and uses markdown serialization. */
/** Number of visible text rows. When set, renders as a multiline editor. */
rows?: number
/** Available variables for autocomplete when typing `{{` */
suggestionItems?: SuggestionItem[]
/** Auto-focus the editor on mount */
autoFocus?: boolean
/** Called with the live editor instance once it is ready (and null on unmount). Used by ExpandTextDialog to call getMarkdown() on mode switch. */
onEditorReady?: (editor: Editor | null) => void
}
/* ── Styled wrapper matching RichTextEditor styling ── */
@@ -133,17 +138,38 @@ const StyledEditorContent = styled(EditorContent, {
*
* When the user types `{{`, a suggestion dropdown appears anchored to the cursor
* with available variables. Selecting a variable inserts it as a styled mention chip
* that renders as `{{variableName}}` in the output text.
* that renders as `{{variableName}}` in the markdown output.
*
* Content is stored and emitted as markdown. The CustomMention extension's
* markdownTokenizer/parseMarkdown/renderMarkdown hooks ensure `{{variable}}` syntax
* survives markdown round-trips intact. Legacy HTML values are accepted for backward
* compatibility — TipTap renders them correctly and subsequent edits emit markdown.
*
* Serialization matches the UI's RichInput behavior:
* This is the agentflow equivalent of the UI package's RichInput component.
*/
export function VariableInput({ value, onChange, placeholder, disabled = false, rows, suggestionItems, autoFocus }: VariableInputProps) {
export function VariableInput({
value,
onChange,
placeholder,
disabled = false,
rows,
suggestionItems,
autoFocus = false,
onEditorReady
}: VariableInputProps) {
const onChangeRef = useRef(onChange)
useEffect(() => {
onChangeRef.current = onChange
}, [onChange])
// Track the last value we emitted (or loaded) to avoid re-setting content
// when the parent echoes our own onChange value back as the new prop.
const lastEmittedRef = useRef<string>(value || '')
// Capture initial value for the mount effect below so we can depend only on
// `editor` without suppressing the exhaustive-deps rule.
const initialValueRef = useRef(value)
const suggestionConfig = useMemo(
() => (suggestionItems?.length ? createSuggestionConfig(suggestionItems) : undefined),
[suggestionItems]
@@ -151,6 +177,7 @@ export function VariableInput({ value, onChange, placeholder, disabled = false,
const extensions = useMemo(
() => [
Markdown,
StarterKit.configure({
codeBlock: false
}),
@@ -178,25 +205,41 @@ export function VariableInput({ value, onChange, placeholder, disabled = false,
const editor = useEditor({
extensions,
content: value || '',
content: '',
editable: !disabled,
autofocus: autoFocus ?? false,
autofocus: autoFocus ? 'end' : false,
onUpdate: ({ editor: ed }) => {
// Always use HTML serialization. The @tiptap/markdown v3 getMarkdown()
// returns empty strings (known issue with the MarkdownManager in v3.20.4).
// HTML round-trips correctly because the Mention extension's parseHTML
// matches on span[data-type="mention"] with data-id/data-label attributes.
onChangeRef.current(ed.getHTML())
const value = getEditorMarkdown(ed)
lastEmittedRef.current = value
onChangeRef.current(value)
}
})
// Sync external value changes into the editor (e.g. when parent state updates)
// Load initial content once the editor is ready, detecting legacy HTML vs markdown.
// Reads from a ref so only `editor` needs to be in the dep array.
useEffect(() => {
if (editor && value !== editor.getHTML()) {
editor.commands.setContent(value, { emitUpdate: false })
if (!editor || !initialValueRef.current) return
const contentType = isHtmlContent(initialValueRef.current) ? 'html' : 'markdown'
editor.commands.setContent(initialValueRef.current, { emitUpdate: false, contentType })
lastEmittedRef.current = initialValueRef.current
}, [editor])
// Sync genuine external value changes (e.g. parent resets the field programmatically).
useEffect(() => {
if (editor && value !== lastEmittedRef.current) {
const contentType = isHtmlContent(value) ? 'html' : 'markdown'
editor.commands.setContent(value, { emitUpdate: false, contentType })
lastEmittedRef.current = value
}
}, [editor, value])
// Notify parent when the editor instance is ready (used by ExpandTextDialog to flush
// the current editor state to markdown when switching to Source mode).
useEffect(() => {
onEditorReady?.(editor)
return () => onEditorReady?.(null)
}, [editor, onEditorReady])
// Sync editable state
useEffect(() => {
if (editor) editor.setEditable(!disabled)
@@ -0,0 +1,120 @@
import { getEditorMarkdown, isHtmlContent } from '../../atoms/utils/editorUtils'
describe('editorUtils', () => {
describe('isHtmlContent', () => {
// Falsy / non-string inputs
it('returns false for empty string', () => {
expect(isHtmlContent('')).toBe(false)
})
it('returns false for null', () => {
expect(isHtmlContent(null)).toBe(false)
})
it('returns false for undefined', () => {
expect(isHtmlContent(undefined)).toBe(false)
})
it('returns false for a number', () => {
expect(isHtmlContent(42)).toBe(false)
})
// Markdown content (must NOT be detected as HTML)
it('returns false for plain text', () => {
expect(isHtmlContent('You are a helpful assistant.')).toBe(false)
})
it('returns false for markdown heading', () => {
expect(isHtmlContent('## My heading')).toBe(false)
})
it('returns false for markdown bullet list', () => {
expect(isHtmlContent('- item 1\n- item 2')).toBe(false)
})
it('returns false for markdown with variable syntax', () => {
expect(isHtmlContent('Hello {{question}}, today is {{current_date_time}}')).toBe(false)
})
it('returns false for markdown code fence', () => {
expect(isHtmlContent('```js\nconsole.log("hello")\n```')).toBe(false)
})
it('returns false for markdown bold/italic', () => {
expect(isHtmlContent('**bold** and *italic* text')).toBe(false)
})
// Legacy HTML content (MUST be detected)
it('returns true for <p> tag', () => {
expect(isHtmlContent('<p>Hello world</p>')).toBe(true)
})
it('returns true for <p> with variable mention span', () => {
expect(isHtmlContent('<p>Hello <span data-type="mention" data-id="question">{{question}}</span></p>')).toBe(true)
})
it('returns true for <h1> heading tag', () => {
expect(isHtmlContent('<h1>Title</h1>')).toBe(true)
})
it('returns true for <h2> heading tag', () => {
expect(isHtmlContent('<h2>Section</h2>')).toBe(true)
})
it('returns true for <ul> list tag', () => {
expect(isHtmlContent('<ul><li>item</li></ul>')).toBe(true)
})
it('returns true for <pre><code> block', () => {
expect(isHtmlContent('<pre><code>const x = 1</code></pre>')).toBe(true)
})
it('returns true for <strong> tag', () => {
expect(isHtmlContent('<strong>bold</strong>')).toBe(true)
})
it('returns true for <em> tag', () => {
expect(isHtmlContent('<em>italic</em>')).toBe(true)
})
it('returns true for <blockquote> tag', () => {
expect(isHtmlContent('<blockquote>quote</blockquote>')).toBe(true)
})
it('returns true for multiline HTML with mixed content', () => {
expect(isHtmlContent('<p>First paragraph</p>\n<p>Second paragraph</p>')).toBe(true)
})
it('returns true for uppercase tag like <P>', () => {
expect(isHtmlContent('<P>uppercase</P>')).toBe(true)
})
})
describe('getEditorMarkdown', () => {
it('returns markdown when getMarkdown() returns a non-empty string', () => {
const editor = { getMarkdown: () => '## heading', getHTML: () => '<h2>heading</h2>', isEmpty: false }
expect(getEditorMarkdown(editor)).toBe('## heading')
})
it('returns empty string when getMarkdown() returns "" and editor is empty', () => {
const editor = { getMarkdown: () => '', getHTML: () => '', isEmpty: true }
expect(getEditorMarkdown(editor)).toBe('')
})
it('falls back to HTML when getMarkdown() returns "" but editor is not empty', () => {
const editor = { getMarkdown: () => '', getHTML: () => '<p>hello</p>', isEmpty: false }
expect(getEditorMarkdown(editor)).toBe('<p>hello</p>')
})
it('falls back to HTML when getMarkdown() throws', () => {
const editor = {
getMarkdown: () => {
throw new Error('serialization failed')
},
getHTML: () => '<p>fallback</p>',
isEmpty: false
}
expect(getEditorMarkdown(editor)).toBe('<p>fallback</p>')
})
})
})
@@ -0,0 +1,30 @@
/**
* Returns true when `content` looks like legacy HTML (saved by the old getHTML()
* serializer), so callers can choose the right TipTap `setContent` contentType.
*
* Ported from packages/ui/src/ui-component/input/RichInput.jsx (PR #6021).
* A simple tag-presence check is intentionally used here — it is fast, has no
* dependencies, and the false-positive/negative risk for agentflow node inputs
* is negligible (users don't normally write raw HTML into prompt fields).
*/
export function isHtmlContent(content: unknown): boolean {
if (!content || typeof content !== 'string') return false
return /<(?:p|div|span|h[1-6]|ul|ol|li|br|code|pre|blockquote|table|strong|em)\b/i.test(content)
}
/**
* Safely serialise a TipTap editor to markdown, falling back to HTML when
* `getMarkdown()` returns an empty string for a non-empty document — a known
* issue in `@tiptap/markdown` v3 where the MarkdownManager may silently fail
* to serialise certain node types.
*/
export function getEditorMarkdown(editor: { getMarkdown(): string; getHTML(): string; isEmpty: boolean }): string {
try {
const markdown = editor.getMarkdown()
if (markdown) return markdown
if (editor.isEmpty) return ''
return editor.getHTML()
} catch {
return editor.getHTML()
}
}
@@ -0,0 +1 @@
export { getEditorMarkdown, isHtmlContent } from './editorUtils'
@@ -367,8 +367,8 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog
inputParam={inputParam}
data={data}
disabled={dialogProps.disabled}
onDataChange={onCustomDataChange}
variableItems={variableItems}
onDataChange={onCustomDataChange}
/>
)
}
+3
View File
@@ -144,6 +144,9 @@ importers:
'@tiptap/extension-placeholder':
specifier: ^3.20.4
version: 3.20.4(@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))
'@tiptap/markdown':
specifier: ^3.20.4
version: 3.20.4(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
'@tiptap/pm':
specifier: ^3.20.4
version: 3.20.4