Feat/agentflow add support for Variable System & Syntax Support (#6067)

* Initial flow

* Add tiptap - mention dependency similar to v2

* Add functionalities similiar to V2

* update to tiptap to 3.20.4

* Fix lint error

* Added missiing test cases for new files

* Styling changes

* InputMessage should be visible on initial load

* Fix gemini comment

* Remove outdated SelectVariable

* Add icon support similar to v2

* Fix gemini comments

* Remove input folder and utils folder under atoms

* Fix test error

* Fix initlalDefualts not being set

* Fix after merge with main

* Fixes for review comments

* Fix review comments

* New test cases added for fixes

* Remove additional space
This commit is contained in:
j-sanaa
2026-03-31 14:15:52 -07:00
committed by GitHub
parent faa571c961
commit e2351c5821
33 changed files with 1753 additions and 539 deletions
+1
View File
@@ -20,6 +20,7 @@ const baseConfig = {
// so we redirect to lightweight CJS stubs under src/__mocks__/.
'^@tiptap/(.+)$': '<rootDir>/src/__mocks__/@tiptap/$1.ts',
'^lowlight$': '<rootDir>/src/__mocks__/lowlight.ts',
'^tippy\\.js$': '<rootDir>/src/__mocks__/tippy.js.ts',
// Bypass React.lazy wrappers — resolve Foo.lazy → Foo so tests render synchronously
'(.*)\\.lazy$': '$1'
}
+8 -4
View File
@@ -72,10 +72,13 @@
"@codemirror/lang-json": "^6.0.0",
"@codemirror/lang-python": "^6.1.0",
"@tabler/icons-react": "^3.7.0",
"@tiptap/extension-code-block-lowlight": "^3.4.3",
"@tiptap/extension-placeholder": "^2.11.5",
"@tiptap/react": "^2.11.5",
"@tiptap/starter-kit": "^2.11.5",
"@tiptap/core": "^3.20.4",
"@tiptap/extension-code-block-lowlight": "^3.20.4",
"@tiptap/extension-mention": "^3.20.4",
"@tiptap/extension-placeholder": "^3.20.4",
"@tiptap/pm": "^3.20.4",
"@tiptap/react": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4",
"@uiw/codemirror-theme-sublime": "^4.21.0",
"@uiw/codemirror-theme-vscode": "^4.21.0",
"@uiw/react-codemirror": "^4.21.0",
@@ -85,6 +88,7 @@
"html-react-parser": "^3.0.16",
"lodash": "^4.17.21",
"lowlight": "^3.3.0",
"tippy.js": "^6.3.7",
"uuid": "^10.0.0"
},
"devDependencies": {
@@ -0,0 +1,10 @@
export const mergeAttributes = jest.fn((...attrs: Record<string, unknown>[]) => Object.assign({}, ...attrs))
export class PasteRule {
find: unknown
handler: unknown
constructor(config: Record<string, unknown>) {
this.find = config.find
this.handler = config.handler
}
}
@@ -0,0 +1,7 @@
const Mention = {
extend: jest.fn(() => ({
configure: jest.fn(() => 'CustomMention')
})),
configure: jest.fn(() => 'Mention')
}
export default Mention
@@ -0,0 +1 @@
export const Markdown = { configure: jest.fn(() => 'Markdown') }
@@ -0,0 +1,4 @@
export const model = {}
export const state = {}
export const view = {}
export const transform = {}
@@ -2,6 +2,7 @@ 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
@@ -11,3 +12,10 @@ export const EditorContent = forwardRef<HTMLDivElement, { editor?: unknown; [k:
createElement('div', { ref, 'data-testid': 'tiptap-editor-content', 'data-has-editor': !!editor, ...rest })
)
EditorContent.displayName = 'EditorContent'
export const ReactRenderer = jest.fn().mockImplementation(() => ({
element: document.createElement('div'),
ref: null,
updateProps: jest.fn(),
destroy: jest.fn()
}))
@@ -0,0 +1,9 @@
const tippy = jest.fn(() => [
{
setProps: jest.fn(),
destroy: jest.fn(),
hide: jest.fn(),
show: jest.fn()
}
])
export default tippy
+3 -3
View File
@@ -4,8 +4,8 @@ import { FormControl, Popover } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import ReactJson from 'flowise-react-json-view'
import type { VariableItem } from './SelectVariable'
import { SelectVariable } from './SelectVariable'
import type { VariableItem } from './VariablePicker'
import { VariablePicker } from './VariablePicker'
export interface JsonInputProps {
value: string
@@ -153,7 +153,7 @@ export function JsonInput({ value, onChange, disabled = false, variableItems }:
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
slotProps={{ paper: { sx: { width: 320, maxHeight: 400 } } }}
>
<SelectVariable
<VariablePicker
items={variableItems!}
onSelect={(val) => {
setNewVal(val)
@@ -36,6 +36,12 @@ jest.mock('./RichTextEditor.lazy', () => ({
)
}))
jest.mock('./VariableInput', () => ({
VariableInput: ({ suggestionItems }: { suggestionItems?: { id: string }[] }) => (
<div data-testid='variable-input' data-suggestion-ids={JSON.stringify(suggestionItems?.map((i) => i.id))} />
)
}))
jest.mock('@tabler/icons-react', () => ({
IconArrowsMaximize: () => <span data-testid='icon-expand' />,
IconInfoCircle: () => <span data-testid='icon-info-circle' />,
@@ -328,8 +334,8 @@ jest.mock('./JsonInput', () => ({
)
}))
jest.mock('./SelectVariable', () => ({
SelectVariable: ({ items, onSelect }: { items: Array<{ value: string }>; onSelect: (v: string) => void }) => (
jest.mock('./VariablePicker', () => ({
VariablePicker: ({ items, onSelect }: { items: Array<{ value: string }>; onSelect: (v: string) => void }) => (
<div data-testid='select-variable'>
{items.map((item, i) => (
<button key={i} data-testid={`var-${item.value}`} onClick={() => onSelect(item.value)}>
@@ -503,6 +509,28 @@ describe('NodeInputHandler – variable popover', () => {
expect(screen.queryByTestId('icon-variable')).toBeNull()
})
it('deduplicates suggestionItem ids when variableItems share the same value', () => {
// Two flow-state entries with the same key produce the same base id.
// The first should keep its id; subsequent duplicates get a __N suffix.
const variableItems = [
{ label: '$flow.state.myVar', value: '$flow.state.myVar', category: 'Flow State' },
{ label: '$flow.state.myVar', value: '$flow.state.myVar', category: 'Flow State' },
{ label: '$flow.state.other', value: '$flow.state.other', category: 'Flow State' }
]
render(
<NodeInputHandler
inputParam={makeParam({ type: 'string', acceptVariable: true })}
data={baseNodeData}
isAdditionalParams
onDataChange={mockOnDataChange}
variableItems={variableItems}
/>
)
const ids = JSON.parse(screen.getByTestId('variable-input').getAttribute('data-suggestion-ids')!)
expect(ids).toEqual(['$flow.state.myVar', '$flow.state.myVar__1', '$flow.state.other'])
})
})
describe('NodeInputHandler – credential type rendering', () => {
@@ -27,10 +27,12 @@ import { Dropdown } from './Dropdown'
import { ExpandTextDialog } from './ExpandTextDialog'
import { JsonInput } from './JsonInput'
import { RichTextEditor } from './RichTextEditor.lazy'
import type { VariableItem } from './SelectVariable'
import { SelectVariable } from './SelectVariable'
import { SuggestionItem } from './SuggestionDropdown'
import { SwitchInput } from './SwitchInput'
import { TooltipWithParser } from './TooltipWithParser'
import { VariableInput } from './VariableInput'
import type { VariableItem } from './VariablePicker'
import { VariablePicker } from './VariablePicker'
const CustomWidthTooltip = styled(({ className, ...props }: TooltipProps) => <Tooltip {...props} classes={{ popper: className }} />)({
[`& .${tooltipClasses.tooltip}`]: {
@@ -86,7 +88,7 @@ export interface NodeInputHandlerProps {
arrayIndex?: number | null
/** For array-based configs: the parent array InputParam definition. */
parentArrayParam?: InputParam | null
/** Variable items for the SelectVariable popover (injected from features layer). */
/** Variable items for the VariablePicker popover (injected from features layer). */
variableItems?: VariableItem[]
}
@@ -178,6 +180,25 @@ export function NodeInputHandler({
['string', 'password', 'code'].includes(inputParam?.type ?? '')
)
// Map VariableItem[] to SuggestionItem[] for the inline autocomplete.
// ids must be unique for correct findIndex lookups and React keys — append
// a counter suffix when the same base id appears more than once.
const suggestionItems: SuggestionItem[] | undefined = useMemo(() => {
if (!inputParam?.acceptVariable || !variableItems || variableItems.length === 0) return undefined
const idCount = new Map<string, number>()
return variableItems.map((v) => {
const baseId = v.value.replace(/{{|}}/g, '')
const count = idCount.get(baseId) ?? 0
idCount.set(baseId, count + 1)
return {
id: count === 0 ? baseId : `${baseId}__${count}`,
label: v.label,
description: v.description,
category: v.category
}
})
}, [inputParam?.acceptVariable, variableItems])
const renderInput = () => {
if (!inputParam) return null
@@ -185,6 +206,20 @@ export function NodeInputHandler({
switch (inputParam.type) {
case 'string':
// When acceptVariable is enabled and suggestions are available, use VariableInput
// which provides inline {{ autocomplete
if (suggestionItems && suggestionItems.length > 0) {
return (
<VariableInput
value={typeof value === 'string' ? value : ''}
onChange={(v) => handleDataChange(v)}
placeholder={inputParam.placeholder}
disabled={disabled}
rows={inputParam.rows}
suggestionItems={suggestionItems}
/>
)
}
if (isExpandable) {
return (
<RichTextEditor
@@ -444,12 +479,15 @@ export function NodeInputHandler({
</Typography>
<div style={{ flexGrow: 1 }} />
{showVariableButton && (
<Tooltip title='Select variable'>
<Tooltip title={suggestionItems?.length ? 'Type {{ to select variables' : 'Select variable'}>
<IconButton
size='small'
sx={{ height: 25, width: 25 }}
disabled={disabled}
onClick={(e) => setVariableAnchorEl(e.currentTarget)}
onClick={(e) =>
(inputParam?.type !== 'string' || !suggestionItems?.length) &&
setVariableAnchorEl(e.currentTarget)
}
>
<IconVariable size={20} style={{ color: theme.palette.info.main }} />
</IconButton>
@@ -500,7 +538,7 @@ export function NodeInputHandler({
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
slotProps={{ paper: { sx: { width: 320, maxHeight: 400 } } }}
>
<SelectVariable items={variableItems!} onSelect={handleVariableSelect} />
<VariablePicker items={variableItems!} onSelect={handleVariableSelect} />
</Popover>
)}
@@ -37,18 +37,9 @@ export interface RichTextEditorProps {
/* ── TipTap extensions (no mention/variable support — that belongs in features/) ── */
// Use .extend() to set enableTabIndentation/tabSize because the v2 types from
// @tiptap/extension-code-block (peer dep) don't include these v3-only options.
// See: https://github.com/ueberdosis/tiptap/issues/7613
const CustomCodeBlock = CodeBlockLowlight.extend({
addOptions() {
return { ...this.parent?.(), lowlight, enableTabIndentation: true, tabSize: 2 }
}
})
const buildExtensions = (placeholder?: string) => [
StarterKit.configure({ codeBlock: false }),
CustomCodeBlock,
CodeBlockLowlight.configure({ lowlight, enableTabIndentation: true, tabSize: 2 }),
...(placeholder ? [Placeholder.configure({ placeholder })] : [])
]
@@ -168,7 +159,7 @@ export function RichTextEditor({ value, onChange, placeholder, disabled = false,
// Sync external value changes into the editor (e.g. when parent state updates)
useEffect(() => {
if (editor && value !== editor.getHTML()) {
editor.commands.setContent(value, false)
editor.commands.setContent(value, { emitUpdate: false })
}
}, [editor, value])
@@ -1,69 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import type { VariableItem } from './SelectVariable'
import { SelectVariable } from './SelectVariable'
jest.mock('@tabler/icons-react', () => ({
IconBinaryTree: () => <span data-testid='icon-tree' />,
IconHistory: () => <span data-testid='icon-history' />,
IconMessageChatbot: () => <span data-testid='icon-message' />,
IconPaperclip: () => <span data-testid='icon-paperclip' />
}))
const mockItems: VariableItem[] = [
{ label: 'question', description: "User's question", category: 'Chat Context', value: '{{question}}' },
{ label: 'chat_history', description: 'Past history', category: 'Chat Context', value: '{{chat_history}}' },
{ label: 'Start', description: 'Output from Start', category: 'Node Outputs', value: '{{start_0.data.instance}}' }
]
describe('SelectVariable', () => {
it('renders the title', () => {
render(<SelectVariable items={mockItems} onSelect={jest.fn()} />)
expect(screen.getByText('Select Variable')).toBeInTheDocument()
})
it('renders all items', () => {
render(<SelectVariable items={mockItems} onSelect={jest.fn()} />)
expect(screen.getByText('question')).toBeInTheDocument()
expect(screen.getByText('chat_history')).toBeInTheDocument()
expect(screen.getByText('Start')).toBeInTheDocument()
})
it('renders descriptions', () => {
render(<SelectVariable items={mockItems} onSelect={jest.fn()} />)
expect(screen.getByText("User's question")).toBeInTheDocument()
expect(screen.getByText('Output from Start')).toBeInTheDocument()
})
it('calls onSelect with the item value when clicked', () => {
const onSelect = jest.fn()
render(<SelectVariable items={mockItems} onSelect={onSelect} />)
fireEvent.click(screen.getByText('question'))
expect(onSelect).toHaveBeenCalledWith('{{question}}')
})
it('returns null when disabled', () => {
const { container } = render(<SelectVariable items={mockItems} onSelect={jest.fn()} disabled />)
expect(container.firstChild).toBeNull()
})
it('returns null when items is empty', () => {
const { container } = render(<SelectVariable items={[]} onSelect={jest.fn()} />)
expect(container.firstChild).toBeNull()
})
it('renders items as a flat list without category headers', () => {
render(<SelectVariable items={mockItems} onSelect={jest.fn()} />)
// Should NOT have category headers like "CHAT CONTEXT" or "NODE OUTPUTS"
expect(screen.queryByText('Chat Context')).not.toBeInTheDocument()
expect(screen.queryByText('Node Outputs')).not.toBeInTheDocument()
})
})
@@ -1,83 +0,0 @@
import { Box, List, ListItem, ListItemAvatar, ListItemButton, ListItemText, Stack, Typography } from '@mui/material'
import { IconBinaryTree, IconHistory, IconMessageChatbot, IconPaperclip } from '@tabler/icons-react'
export interface VariableItem {
label: string
description?: string
category?: string
value: string
}
export interface SelectVariableProps {
items: VariableItem[]
disabled?: boolean
onSelect: (variableString: string) => void
}
/** Maps each category to an icon and color matching the original Flowise SelectVariable. */
const CATEGORY_STYLE: Record<string, { icon: React.ElementType; color: string }> = {
'Chat Context': { icon: IconMessageChatbot, color: '#6EC6E6' },
'Node Outputs': { icon: IconHistory, color: '#64B5F6' },
'Flow State': { icon: IconBinaryTree, color: '#FFA07A' }
}
const DEFAULT_STYLE = { icon: IconPaperclip, color: '#90A4AE' }
/**
* Presentational variable picker atom.
*
* Renders a flat, scrollable list of variables matching the original Flowise
* SelectVariable styling — 50x50 white circle avatars with colored icons,
* no category headers, consistent card-like item spacing.
*/
export function SelectVariable({ items, disabled = false, onSelect }: SelectVariableProps) {
if (disabled || items.length === 0) return null
return (
<div style={{ flex: 30 }}>
<Stack flexDirection='row' sx={{ mb: 1, ml: 2, mt: 2 }}>
<Typography variant='h5'>Select Variable</Typography>
</Stack>
<Box sx={{ maxHeight: 'calc(100vh - 220px)', overflowY: 'auto', overflowX: 'hidden', px: 2 }}>
<List>
{items.map((item, idx) => {
const style = CATEGORY_STYLE[item.category ?? ''] || DEFAULT_STYLE
const Icon = style.icon
return (
<ListItemButton
key={`${item.value}-${idx}`}
sx={{
p: 0,
borderRadius: '8px',
boxShadow: '0 2px 14px 0 rgb(32 40 45 / 8%)',
mb: 1
}}
onClick={() => onSelect(item.value)}
>
<ListItem alignItems='center'>
<ListItemAvatar>
<Box
sx={{
width: 50,
height: 50,
borderRadius: '50%',
backgroundColor: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Icon size={30} stroke={1.5} color={style.color} />
</Box>
</ListItemAvatar>
<ListItemText sx={{ ml: 1 }} primary={item.label} secondary={item.description} />
</ListItem>
</ListItemButton>
)
})}
</List>
</Box>
</div>
)
}
@@ -0,0 +1,114 @@
import { createRef, type Ref } from 'react'
import { createTheme, ThemeProvider } from '@mui/material/styles'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { SuggestionDropdown, type SuggestionDropdownRef, type SuggestionItem } from './SuggestionDropdown'
const theme = createTheme()
const ITEMS: SuggestionItem[] = [
{ id: 'question', label: 'question', description: "User's question", category: 'Chat Context' },
{ id: 'chat_history', label: 'chat_history', description: 'Conversation history', category: 'Chat Context' },
{ id: 'node1.data.instance', label: 'LLM Node', description: 'Output from LLM', category: 'Node Outputs' },
{ id: '$flow.state.count', label: '$flow.state.count', description: 'State variable', category: 'Flow State' }
]
function renderDropdown(props: Partial<React.ComponentProps<typeof SuggestionDropdown>> = {}, ref?: Ref<SuggestionDropdownRef>) {
const defaultProps = {
items: ITEMS,
command: jest.fn(),
...props
}
const result = render(
<ThemeProvider theme={theme}>
<SuggestionDropdown ref={ref} {...defaultProps} />
</ThemeProvider>
)
return { ...result, ...defaultProps }
}
describe('SuggestionDropdown', () => {
it('renders grouped items by category', () => {
renderDropdown()
expect(screen.getByText('Chat Context')).toBeInTheDocument()
expect(screen.getByText('Node Outputs')).toBeInTheDocument()
expect(screen.getByText('Flow State')).toBeInTheDocument()
expect(screen.getByText('question')).toBeInTheDocument()
expect(screen.getByText('LLM Node')).toBeInTheDocument()
})
it('renders nothing when items array is empty', () => {
renderDropdown({ items: [] })
expect(screen.queryByTestId('suggestion-dropdown')).not.toBeInTheDocument()
})
it('calls command with correct attrs when item is clicked', () => {
const { command } = renderDropdown()
fireEvent.click(screen.getByText('question'))
expect(command).toHaveBeenCalledWith({ id: 'question', label: 'question' })
})
it('shows descriptions for items', () => {
renderDropdown()
expect(screen.getByText("User's question")).toBeInTheDocument()
expect(screen.getByText('Output from LLM')).toBeInTheDocument()
})
it('exposes onKeyDown via ref for Enter selection', () => {
const ref = createRef<SuggestionDropdownRef>()
const { command } = renderDropdown({}, ref)
// Enter selects first item (index 0)
const handled = ref.current!.onKeyDown({ event: new KeyboardEvent('keydown', { key: 'Enter' }) })
expect(handled).toBe(true)
expect(command).toHaveBeenCalledWith({ id: 'question', label: 'question' })
})
it('exposes onKeyDown via ref for ArrowDown navigation', () => {
const ref = createRef<SuggestionDropdownRef>()
const { command } = renderDropdown({}, ref)
// ArrowDown moves to index 1 — wrap in act() so React flushes state
act(() => {
ref.current!.onKeyDown({ event: new KeyboardEvent('keydown', { key: 'ArrowDown' }) })
})
// Enter selects index 1
act(() => {
ref.current!.onKeyDown({ event: new KeyboardEvent('keydown', { key: 'Enter' }) })
})
expect(command).toHaveBeenCalledWith({ id: 'chat_history', label: 'chat_history' })
})
it('exposes onKeyDown via ref for ArrowUp navigation (wraps around)', () => {
const ref = createRef<SuggestionDropdownRef>()
const { command } = renderDropdown({}, ref)
// ArrowUp from index 0 wraps to last — wrap in act() so React flushes state
act(() => {
ref.current!.onKeyDown({ event: new KeyboardEvent('keydown', { key: 'ArrowUp' }) })
})
act(() => {
ref.current!.onKeyDown({ event: new KeyboardEvent('keydown', { key: 'Enter' }) })
})
expect(command).toHaveBeenCalledWith({ id: '$flow.state.count', label: '$flow.state.count' })
})
it('returns false for unhandled keys', () => {
const ref = createRef<SuggestionDropdownRef>()
renderDropdown({}, ref)
const handled = ref.current!.onKeyDown({ event: new KeyboardEvent('keydown', { key: 'a' }) })
expect(handled).toBe(false)
})
})
@@ -0,0 +1,163 @@
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { Divider, List, ListItem, ListItemButton, Paper, Typography } from '@mui/material'
import { useTheme } from '@mui/material/styles'
/**
* Shape of items returned by the TipTap suggestion `items()` callback.
* Matches the UI package's SuggestionList item shape.
*/
export interface SuggestionItem {
id: string
/** Display label (called `mentionLabel` in the UI package) */
label: string
description?: string
category?: string
}
export interface SuggestionDropdownProps {
/** Filtered suggestion items from TipTap's suggestion plugin */
items: SuggestionItem[]
/** TipTap command to insert the selected mention node */
command: (attrs: { id: string; label: string }) => void
}
/** Ref handle exposed to TipTap's suggestion `onKeyDown` callback. */
export interface SuggestionDropdownRef {
onKeyDown: (args: { event: KeyboardEvent }) => boolean
}
/**
* Autocomplete dropdown for TipTap mention suggestions.
*
* Rendered by TipTap's suggestion plugin via ReactRenderer.
* Exposes keyboard navigation via forwardRef + useImperativeHandle
* so the suggestion plugin can delegate keystrokes.
*
* Port of packages/ui/src/ui-component/input/SuggestionList.jsx to TypeScript.
*/
export const SuggestionDropdown = forwardRef<SuggestionDropdownRef, SuggestionDropdownProps>(({ items, command }, ref) => {
const theme = useTheme()
const [selectedIndex, setSelectedIndex] = useState(0)
const listRef = useRef<HTMLUListElement>(null)
// Group items by category
const grouped = useMemo(() => {
const groups: Record<string, SuggestionItem[]> = {}
for (const item of items) {
const cat = item.category ?? 'Other'
if (!groups[cat]) groups[cat] = []
groups[cat].push(item)
}
return groups
}, [items])
// Reset selection when items change
useEffect(() => {
setSelectedIndex(0)
}, [items])
// Scroll selected item into view
useEffect(() => {
if (listRef.current) {
const selected = listRef.current.querySelector('[data-selected="true"]')
selected?.scrollIntoView?.({ block: 'nearest' })
}
}, [selectedIndex])
const selectItem = (index: number) => {
if (index >= items.length) return
const item = items[index]
command({ id: item.id, label: item.label })
}
// Expose keyboard navigation to TipTap's suggestion plugin
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === 'ArrowUp') {
setSelectedIndex((prev) => (prev + items.length - 1) % items.length)
return true
}
if (event.key === 'ArrowDown') {
setSelectedIndex((prev) => (prev + 1) % items.length)
return true
}
if (event.key === 'Enter') {
selectItem(selectedIndex)
return true
}
return false
}
}))
if (items.length === 0) return null
return (
<Paper
elevation={5}
sx={{
maxHeight: 300,
overflowY: 'auto',
zIndex: theme.zIndex.modal + 1
}}
data-testid='suggestion-dropdown'
>
<List ref={listRef} dense sx={{ overflow: 'hidden', maxWidth: 300 }}>
{Object.entries(grouped).map(([category, categoryItems], categoryIndex) => (
<div key={category}>
{categoryIndex > 0 && <Divider />}
<ListItem
sx={{
py: 0.5,
bgcolor: theme.palette.mode === 'dark' ? theme.palette.common.black : theme.palette.grey[50]
}}
>
<Typography variant='overline' color='text.secondary'>
{category}
</Typography>
</ListItem>
{categoryItems.map((item) => {
const itemIndex = items.findIndex((i) => i.id === item.id)
const isSelected = itemIndex === selectedIndex
return (
<ListItem key={item.id} disablePadding>
<ListItemButton
data-selected={isSelected}
selected={isSelected}
onClick={() => selectItem(itemIndex)}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<Typography variant='body1' sx={{ fontWeight: 500 }}>
{item.label}
</Typography>
{item.description && (
<Typography
variant='caption'
color='text.secondary'
sx={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{item.description}
</Typography>
)}
</ListItemButton>
</ListItem>
)
})}
</div>
))}
</List>
</Paper>
)
})
SuggestionDropdown.displayName = 'SuggestionDropdown'
@@ -0,0 +1,87 @@
import { createTheme, ThemeProvider } from '@mui/material/styles'
import { render, screen } from '@testing-library/react'
import * as TiptapReact from '@tiptap/react'
import { VariableInput, type VariableInputProps } from './VariableInput'
const theme = createTheme()
function renderVariableInput(props: Partial<VariableInputProps> = {}) {
const defaultProps: VariableInputProps = {
value: '',
onChange: jest.fn(),
...props
}
const result = render(
<ThemeProvider theme={theme}>
<VariableInput {...defaultProps} />
</ThemeProvider>
)
return { ...result, ...defaultProps }
}
describe('VariableInput', () => {
it('renders the TipTap editor container', () => {
renderVariableInput()
expect(screen.getByTestId('variable-input')).toBeInTheDocument()
})
it('renders the TipTap editor content element', () => {
renderVariableInput()
// The mocked EditorContent renders a div with data-testid="tiptap-editor-content"
expect(screen.getByTestId('tiptap-editor-content')).toBeInTheDocument()
})
it('passes editor instance to EditorContent', () => {
renderVariableInput({ value: 'Hello' })
const editorContent = screen.getByTestId('tiptap-editor-content')
expect(editorContent).toHaveAttribute('data-has-editor', 'true')
})
it('renders without suggestion items', () => {
renderVariableInput({ suggestionItems: undefined })
expect(screen.getByTestId('variable-input')).toBeInTheDocument()
})
it('renders with suggestion items', () => {
renderVariableInput({
suggestionItems: [{ id: 'question', label: 'question', description: "User's question", category: 'Chat Context' }]
})
expect(screen.getByTestId('variable-input')).toBeInTheDocument()
})
it('renders with placeholder', () => {
renderVariableInput({ placeholder: 'Type here...' })
expect(screen.getByTestId('variable-input')).toBeInTheDocument()
})
it('renders in disabled state', () => {
renderVariableInput({ disabled: true })
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.
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()
})
})
@@ -0,0 +1,207 @@
import { useEffect, useMemo, useRef } from 'react'
import { Box } from '@mui/material'
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 { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import javascript from 'highlight.js/lib/languages/javascript'
import json from 'highlight.js/lib/languages/json'
import python from 'highlight.js/lib/languages/python'
import typescript from 'highlight.js/lib/languages/typescript'
import { createLowlight } from 'lowlight'
import { CustomMention } from '@/core/primitives/customMention'
import { tokens } from '@/core/theme/tokens'
import { createSuggestionConfig } from './suggestionConfig'
import type { SuggestionItem } from './SuggestionDropdown'
export type { SuggestionItem }
const lowlight = createLowlight()
lowlight.register('javascript', javascript)
lowlight.register('json', json)
lowlight.register('python', python)
lowlight.register('typescript', typescript)
export interface VariableInputProps {
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
/** Number of visible text rows. When set, renders as a multiline editor and uses markdown serialization. */
rows?: number
/** Available variables for autocomplete when typing `{{` */
suggestionItems?: SuggestionItem[]
}
/* ── Styled wrapper matching RichTextEditor styling ── */
const StyledEditorContent = styled(EditorContent, {
shouldForwardProp: (prop) => !['rows', 'disabled'].includes(prop as string)
})<{ rows?: number; disabled?: boolean }>(({ theme, rows, disabled }) => {
const mode = theme.palette.mode === 'dark' ? 'dark' : 'light'
const sh = tokens.colors.syntaxHighlight
return {
'& .ProseMirror': {
padding: '10px 14px',
height: rows ? `${rows * tokens.typography.rowHeightRem}rem` : `${tokens.typography.singleLineHeightRem}rem`,
overflowY: rows ? 'auto' : 'hidden',
overflowX: rows ? 'auto' : 'hidden',
lineHeight: rows ? `${tokens.typography.rowHeightRem}em` : `${tokens.typography.singleLineLineHeightEm}em`,
fontWeight: 500,
color: disabled ? theme.palette.action.disabled : theme.palette.grey[900],
border: `1px solid ${theme.palette.grey[900]}25`,
borderRadius: '10px',
backgroundColor:
(theme.palette as { textBackground?: { main: string } }).textBackground?.main ?? theme.palette.background.paper,
boxSizing: 'border-box',
whiteSpace: rows ? 'pre-wrap' : 'nowrap',
'&:hover': {
borderColor: disabled ? `${theme.palette.grey[900]}25` : theme.palette.text.primary,
cursor: disabled ? 'default' : 'text'
},
'&:focus': {
borderColor: disabled ? `${theme.palette.grey[900]}25` : theme.palette.primary.main,
outline: 'none'
},
// Placeholder
'& p.is-editor-empty:first-of-type::before': {
content: 'attr(data-placeholder)',
float: 'left',
color: disabled ? theme.palette.action.disabled : theme.palette.text.primary,
opacity: disabled ? 0.6 : 0.4,
pointerEvents: 'none',
height: 0
},
// Mention (variable) chip styling — matches original UI green style
'& .mention': {
backgroundColor: mode === 'dark' ? 'rgba(76, 175, 80, 0.2)' : 'rgba(76, 175, 80, 0.08)',
borderRadius: '4px',
padding: '1px 4px',
fontWeight: 600,
color: mode === 'dark' ? '#81c784' : '#2e7d32',
whiteSpace: 'nowrap'
},
// Block element spacing
'& p, & h1, & h2, & h3, & h4, & h5, & h6, & ul, & ol, & pre, & blockquote': {
margin: '0.75em 0'
},
'& > :first-of-type': { marginTop: '0.25em' },
'& > :last-of-type': { marginBottom: '0.25em' },
// List indentation
'& ul, & ol': { paddingLeft: '1.5em' },
'& li': { marginBottom: '0.25em' },
'& li > p': { margin: '0.25em 0' },
// Code block styling
'& pre': {
backgroundColor: sh.background[mode],
color: sh.text[mode],
borderRadius: '8px',
padding: '0.75em 1em',
overflow: 'auto',
'& code': { fontFamily: 'monospace', fontSize: '0.9em' }
},
// Syntax highlight colors
'& .hljs-comment, & .hljs-quote': { color: sh.comment[mode] },
'& .hljs-variable, & .hljs-template-variable, & .hljs-attr': { color: sh.variable[mode] },
'& .hljs-number, & .hljs-literal': { color: sh.number[mode] },
'& .hljs-string, & .hljs-regexp': { color: sh.string[mode] },
'& .hljs-title, & .hljs-section, & .hljs-selector-id': { color: sh.title[mode] },
'& .hljs-keyword, & .hljs-selector-tag, & .hljs-built_in': { color: sh.keyword[mode] },
'& .hljs-operator, & .hljs-symbol': { color: sh.operator[mode] },
'& .hljs-punctuation': { color: sh.punctuation[mode] }
}
}
})
/**
* A TipTap-based text input with `{{ variable }}` mention support.
*
* 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.
*
* 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 }: VariableInputProps) {
const onChangeRef = useRef(onChange)
useEffect(() => {
onChangeRef.current = onChange
}, [onChange])
const suggestionConfig = useMemo(
() => (suggestionItems?.length ? createSuggestionConfig(suggestionItems) : undefined),
[suggestionItems]
)
const extensions = useMemo(
() => [
StarterKit.configure({
codeBlock: false
}),
CodeBlockLowlight.configure({ lowlight, enableTabIndentation: true, tabSize: 2 }),
...(placeholder ? [Placeholder.configure({ placeholder })] : []),
...(suggestionConfig
? [
CustomMention.configure({
HTMLAttributes: { class: 'mention' },
renderHTML({ options, node }) {
return [
'span',
mergeAttributes(this.HTMLAttributes ?? {}, options.HTMLAttributes),
`${options.suggestion?.char ?? '{{'}${node.attrs.label ?? node.attrs.id}}}`
]
},
suggestion: suggestionConfig,
deleteTriggerWithBackspace: true
})
]
: [])
],
[placeholder, suggestionConfig]
)
const editor = useEditor({
extensions,
content: value || '',
editable: !disabled,
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())
}
})
// Sync external value changes into the editor (e.g. when parent state updates)
useEffect(() => {
if (editor && value !== editor.getHTML()) {
editor.commands.setContent(value, { emitUpdate: false })
}
}, [editor, value])
// Sync editable state
useEffect(() => {
if (editor) editor.setEditable(!disabled)
}, [editor, disabled])
return (
<Box data-testid='variable-input' sx={{ mt: 1 }}>
<StyledEditorContent editor={editor} rows={rows} disabled={disabled} />
</Box>
)
}
@@ -0,0 +1,93 @@
import { createTheme, ThemeProvider } from '@mui/material/styles'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { VariableItem } from './VariablePicker'
import { VariablePicker } from './VariablePicker'
const theme = createTheme()
function renderPicker(props: Partial<React.ComponentProps<typeof VariablePicker>> = {}) {
const defaultProps = {
items: [] as VariableItem[],
onSelect: jest.fn(),
...props
}
return render(
<ThemeProvider theme={theme}>
<VariablePicker {...defaultProps} />
</ThemeProvider>
)
}
const VARIABLES: VariableItem[] = [
{ label: 'question', description: "User's question from chatbox", category: 'Chat Context', value: '{{question}}' },
{ label: 'chat_history', description: 'Past conversation history', category: 'Chat Context', value: '{{chat_history}}' },
{ label: 'nodeA', description: 'Output from Node A', category: 'Node Outputs', value: '{{nodeA.data.instance}}' },
{ label: '$flow.state.count', description: 'Counter state', category: 'Flow State', value: '$flow.state.count' }
]
describe('VariablePicker', () => {
it('renders null when disabled', () => {
const { container } = renderPicker({ items: VARIABLES, disabled: true })
expect(container.innerHTML).toBe('')
})
it('renders null when items array is empty', () => {
const { container } = renderPicker({ items: [] })
expect(container.innerHTML).toBe('')
})
it('renders the "Select Variable" heading', () => {
renderPicker({ items: VARIABLES })
expect(screen.getByText('Select Variable')).toBeInTheDocument()
})
it('renders category headers', () => {
renderPicker({ items: VARIABLES })
expect(screen.getByText('Chat Context')).toBeInTheDocument()
expect(screen.getByText('Node Outputs')).toBeInTheDocument()
expect(screen.getByText('Flow State')).toBeInTheDocument()
})
it('renders variable labels and descriptions', () => {
renderPicker({ items: VARIABLES })
expect(screen.getByText('question')).toBeInTheDocument()
expect(screen.getByText("User's question from chatbox")).toBeInTheDocument()
expect(screen.getByText('chat_history')).toBeInTheDocument()
expect(screen.getByText('nodeA')).toBeInTheDocument()
expect(screen.getByText('$flow.state.count')).toBeInTheDocument()
})
it('groups items by category', () => {
renderPicker({ items: VARIABLES })
// Chat Context should contain both question and chat_history
const listItems = screen.getAllByRole('button')
expect(listItems).toHaveLength(4)
})
it('calls onSelect with the variable value when item is clicked', async () => {
const user = userEvent.setup()
const onSelect = jest.fn()
renderPicker({ items: VARIABLES, onSelect })
await user.click(screen.getByText('question'))
expect(onSelect).toHaveBeenCalledWith('{{question}}')
})
it('calls onSelect with the correct value for each item', async () => {
const user = userEvent.setup()
const onSelect = jest.fn()
renderPicker({ items: VARIABLES, onSelect })
await user.click(screen.getByText('$flow.state.count'))
expect(onSelect).toHaveBeenCalledWith('$flow.state.count')
})
it('uses "Other" category for items without a category', () => {
const items: VariableItem[] = [{ label: 'custom', description: 'A custom var', value: '{{custom}}' }]
renderPicker({ items })
expect(screen.getByText('Other')).toBeInTheDocument()
expect(screen.getByText('custom')).toBeInTheDocument()
})
})
@@ -0,0 +1,126 @@
import { useMemo } from 'react'
import { Box, Divider, List, ListItem, ListItemAvatar, ListItemButton, ListItemText, Stack, Typography } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { IconBinaryTree, IconHistory, IconMessageChatbot, IconPaperclip } from '@tabler/icons-react'
export interface VariableItem {
label: string
description?: string
category?: string
value: string
/** Optional per-item icon component (e.g. the upstream node's icon). */
icon?: React.ElementType
/** Optional per-item icon color. Falls back to the category color. */
iconColor?: string
}
export interface VariablePickerProps {
items: VariableItem[]
onSelect: (variableString: string) => void
disabled?: boolean
}
const CATEGORY_STYLE: Record<string, { icon: React.ElementType; color: string }> = {
'Chat Context': { icon: IconMessageChatbot, color: '#6EC6E6' },
'Node Outputs': { icon: IconHistory, color: '#64B5F6' },
'Flow State': { icon: IconBinaryTree, color: '#FFA07A' }
}
const DEFAULT_STYLE = { icon: IconPaperclip, color: '#90A4AE' }
/**
* Grouped variable picker panel. Shows variables organized by category with
* section headers and colored icons. Used in popovers (e.g. JSON editor per-key
* injection, non-TipTap variable selection).
*/
export function VariablePicker({ items, onSelect, disabled = false }: VariablePickerProps) {
const theme = useTheme()
const grouped = useMemo(() => {
const groups: { category: string; items: VariableItem[] }[] = []
const seen = new Map<string, VariableItem[]>()
for (const item of items) {
const cat = item.category ?? 'Other'
if (!seen.has(cat)) {
const arr: VariableItem[] = []
seen.set(cat, arr)
groups.push({ category: cat, items: arr })
}
seen.get(cat)!.push(item)
}
return groups
}, [items])
if (disabled || items.length === 0) return null
return (
<div style={{ flex: 30 }}>
<Stack flexDirection='row' sx={{ mb: 1, ml: 2, mt: 2 }}>
<Typography variant='h5'>Select Variable</Typography>
</Stack>
<Box sx={{ maxHeight: 'calc(100vh - 220px)', overflowY: 'auto', overflowX: 'hidden', px: 2 }}>
{grouped.map((group, groupIdx) => {
const style = CATEGORY_STYLE[group.category] || DEFAULT_STYLE
const Icon = style.icon
return (
<Box key={group.category}>
{groupIdx > 0 && <Divider sx={{ my: 1 }} />}
<Typography
variant='overline'
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
mt: 1,
mb: 0.5,
color: 'text.secondary'
}}
>
<Icon size={16} color={style.color} />
{group.category}
</Typography>
<List disablePadding>
{group.items.map((item, idx) => {
const ItemIcon = item.icon ?? Icon
const itemColor = item.iconColor ?? style.color
return (
<ListItemButton
key={`${item.value}-${idx}`}
sx={{
p: 0,
borderRadius: `${theme.shape.borderRadius}px`,
boxShadow: '0 2px 14px 0 rgb(32 40 45 / 8%)',
mb: 1
}}
onClick={() => onSelect(item.value)}
>
<ListItem alignItems='center'>
<ListItemAvatar>
<Box
sx={{
width: 50,
height: 50,
borderRadius: '50%',
backgroundColor: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<ItemIcon size={30} stroke={1.5} color={itemColor} />
</Box>
</ListItemAvatar>
<ListItemText sx={{ ml: 1 }} primary={item.label} secondary={item.description} />
</ListItem>
</ListItemButton>
)
})}
</List>
</Box>
)
})}
</Box>
</div>
)
}
+3 -1
View File
@@ -9,7 +9,6 @@ export { JsonInput, type JsonInputProps } from './JsonInput'
export { MainCard, type MainCardProps } from './MainCard'
export { type MessageEntry, MessagesInput, type MessagesInputProps } from './MessagesInput'
export { type AsyncInputProps, type ConfigInputComponentProps, NodeInputHandler } from './NodeInputHandler'
export { SelectVariable, type SelectVariableProps, type VariableItem } from './SelectVariable'
// RichTextEditor is exported from the .lazy wrapper (not the real module) to avoid
// eagerly pulling TipTap + highlight.js into the main bundle. Importing directly
// from ./RichTextEditor would defeat code-splitting since barrel imports are resolved eagerly.
@@ -17,5 +16,8 @@ export type { RichTextEditorProps } from './RichTextEditor'
export { RichTextEditor } from './RichTextEditor.lazy'
export { ScenariosInput, type ScenariosInputProps } from './ScenariosInput'
export { StructuredOutputBuilder, type StructuredOutputBuilderProps, type StructuredOutputEntry } from './StructuredOutputBuilder'
export { SuggestionDropdown, type SuggestionDropdownProps, type SuggestionDropdownRef, type SuggestionItem } from './SuggestionDropdown'
export { SwitchInput, type SwitchInputProps } from './SwitchInput'
export { TooltipWithParser, type TooltipWithParserProps } from './TooltipWithParser'
export { VariableInput, type VariableInputProps } from './VariableInput'
export { type VariableItem, VariablePicker, type VariablePickerProps } from './VariablePicker'
@@ -0,0 +1,164 @@
import { ReactRenderer } from '@tiptap/react'
import tippy from 'tippy.js'
import { createSuggestionConfig } from './suggestionConfig'
import type { SuggestionItem } from './SuggestionDropdown'
const ITEMS: SuggestionItem[] = [
{ id: 'question', label: 'question', description: "User's question", category: 'Chat Context' },
{ id: 'chat_history', label: 'chat_history', description: 'Past conversation', category: 'Chat Context' },
{ id: '$flow.state.count', label: '$flow.state.count', description: 'Counter', category: 'Flow State' }
]
describe('createSuggestionConfig', () => {
const config = createSuggestionConfig(ITEMS)
it('uses {{ as trigger character', () => {
expect(config.char).toBe('{{')
})
describe('items() filtering', () => {
const items = config.items as (args: { query: string }) => SuggestionItem[]
it('returns all items when query is empty', () => {
expect(items({ query: '' })).toEqual(ITEMS)
})
it('filters by label (case-insensitive)', () => {
const result = items({ query: 'QUESTION' })
expect(result).toHaveLength(1)
expect(result[0].id).toBe('question')
})
it('filters by id', () => {
const result = items({ query: '$flow.state' })
expect(result).toHaveLength(1)
expect(result[0].id).toBe('$flow.state.count')
})
it('matches partial strings', () => {
const result = items({ query: 'chat' })
expect(result).toHaveLength(1)
expect(result[0].id).toBe('chat_history')
})
it('returns empty array when nothing matches', () => {
expect(items({ query: 'nonexistent' })).toHaveLength(0)
})
})
describe('render lifecycle', () => {
const mockEditor = { view: { dom: document.createElement('div') } }
const mockClientRect = jest.fn(() => new DOMRect(10, 20, 100, 30))
function makeProps(overrides: Record<string, unknown> = {}) {
return {
editor: mockEditor,
range: { from: 0, to: 5 },
query: '',
text: '',
items: ITEMS,
command: jest.fn(),
decorationNode: null,
clientRect: mockClientRect,
...overrides
} as unknown as Parameters<NonNullable<ReturnType<typeof config.render>['onStart']>>[0]
}
beforeEach(() => {
jest.clearAllMocks()
})
it('onStart creates a ReactRenderer and tippy popup', () => {
const lifecycle = config.render()
lifecycle.onStart!(makeProps())
expect(ReactRenderer).toHaveBeenCalledTimes(1)
expect(tippy).toHaveBeenCalledTimes(1)
})
it('onUpdate refreshes component props and popup position', () => {
const lifecycle = config.render()
lifecycle.onStart!(makeProps())
const renderer = (ReactRenderer as jest.Mock).mock.results[0].value
const tippyInstance = (tippy as unknown as jest.Mock).mock.results[0].value[0]
lifecycle.onUpdate!(makeProps())
expect(renderer.updateProps).toHaveBeenCalled()
expect(tippyInstance.setProps).toHaveBeenCalled()
})
it('onKeyDown returns true for Escape and hides popup', () => {
const lifecycle = config.render()
lifecycle.onStart!(makeProps())
const tippyInstance = (tippy as unknown as jest.Mock).mock.results[0].value[0]
const result = lifecycle.onKeyDown!({
view: {} as never,
event: new KeyboardEvent('keydown', { key: 'Escape' }),
range: { from: 0, to: 5 }
})
expect(result).toBe(true)
expect(tippyInstance.hide).toHaveBeenCalled()
})
it('onKeyDown returns false when component ref is null', () => {
const lifecycle = config.render()
lifecycle.onStart!(makeProps())
const result = lifecycle.onKeyDown!({
view: {} as never,
event: new KeyboardEvent('keydown', { key: 'ArrowDown' }),
range: { from: 0, to: 5 }
})
expect(result).toBe(false)
})
it('onKeyDown delegates to component ref when available', () => {
const lifecycle = config.render()
lifecycle.onStart!(makeProps())
const renderer = (ReactRenderer as jest.Mock).mock.results[0].value
const mockOnKeyDown = jest.fn(() => true)
renderer.ref = { onKeyDown: mockOnKeyDown }
const props = {
view: {} as never,
event: new KeyboardEvent('keydown', { key: 'ArrowDown' }),
range: { from: 0, to: 5 }
}
const result = lifecycle.onKeyDown!(props)
expect(mockOnKeyDown).toHaveBeenCalledWith(props)
expect(result).toBe(true)
})
it('onExit destroys component and popup', () => {
const lifecycle = config.render()
lifecycle.onStart!(makeProps())
const renderer = (ReactRenderer as jest.Mock).mock.results[0].value
const tippyInstance = (tippy as unknown as jest.Mock).mock.results[0].value[0]
lifecycle.onExit!()
expect(renderer.destroy).toHaveBeenCalled()
expect(tippyInstance.destroy).toHaveBeenCalled()
})
it('uses DOM_RECT_FALLBACK when clientRect is null', () => {
const lifecycle = config.render()
lifecycle.onStart!(makeProps({ clientRect: null }))
// Verify tippy was called — the fallback is used inside getReferenceClientRect
const tippyConfig = (tippy as unknown as jest.Mock).mock.calls[0][1]
const rect = tippyConfig.getReferenceClientRect()
// Should return the fallback DOMRect (all zeros)
expect(rect.x).toBe(0)
expect(rect.y).toBe(0)
})
})
})
@@ -0,0 +1,117 @@
/**
* TipTap suggestion render lifecycle using tippy.js and ReactRenderer.
*
* Port of packages/ui/src/ui-component/input/suggestionOption.js render() to TypeScript,
* adapted for the agentflow SuggestionDropdown component.
*/
import type { Editor, Range } from '@tiptap/core'
import type { EditorView } from '@tiptap/pm/view'
import { ReactRenderer } from '@tiptap/react'
import tippy, { type Instance as TippyInstance } from 'tippy.js'
import { SuggestionDropdown, type SuggestionDropdownRef, type SuggestionItem } from './SuggestionDropdown'
interface SuggestionCallbackProps {
editor: Editor
range: Range
query: string
text: string
items: SuggestionItem[]
command: (props: SuggestionItem) => void
decorationNode: Element | null
clientRect?: (() => DOMRect | null) | null
}
interface SuggestionKeyDownCallbackProps {
view: EditorView
event: KeyboardEvent
range: Range
}
/**
* Workaround for the typing incompatibility between Tippy.js and TipTap Suggestion utility.
* @see https://github.com/ueberdosis/tiptap/issues/2795#issuecomment-1160623792
*/
const DOM_RECT_FALLBACK: DOMRect = {
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
x: 0,
y: 0,
toJSON() {
return {}
}
}
export interface SuggestionConfigOptions {
items: SuggestionItem[]
}
/**
* Creates the TipTap suggestion config object for the CustomMention extension.
*
* @param suggestionItems - The full list of available variable items.
* Filtering by query is done inside the `items` callback.
*/
export function createSuggestionConfig(suggestionItems: SuggestionItem[]) {
return {
char: '{{',
items: ({ query }: { query: string }) => {
if (!query) return suggestionItems
const q = query.toLowerCase()
return suggestionItems.filter((item) => item.label.toLowerCase().includes(q) || item.id.toLowerCase().includes(q))
},
render: () => {
let component: ReactRenderer<SuggestionDropdownRef> | undefined
let popup: TippyInstance | undefined
return {
onStart: (props: SuggestionCallbackProps) => {
component = new ReactRenderer(SuggestionDropdown, {
props,
editor: props.editor
})
const [instance] = tippy('body', {
getReferenceClientRect: () => props.clientRect?.() ?? DOM_RECT_FALLBACK,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start'
})
popup = instance
},
onUpdate(props: SuggestionCallbackProps) {
component?.updateProps(props)
popup?.setProps({
getReferenceClientRect: () => props.clientRect?.() ?? DOM_RECT_FALLBACK
})
},
onKeyDown(props: SuggestionKeyDownCallbackProps) {
if (props.event.key === 'Escape') {
popup?.hide()
return true
}
if (!component?.ref) return false
return component.ref.onKeyDown(props)
},
onExit() {
popup?.destroy()
component?.destroy()
popup = undefined
component = undefined
}
}
}
}
}
@@ -0,0 +1,111 @@
// Import triggers Mention.extend() at module level — must come before we read mock.calls.
import './customMention'
import { PasteRule } from '@tiptap/core'
import Mention from '@tiptap/extension-mention'
// Grab the config object passed to Mention.extend() so we can test each property.
const extendConfig = (Mention.extend as jest.Mock).mock.calls[0][0] as Record<string, unknown>
describe('CustomMention extension config', () => {
describe('renderText', () => {
const renderText = extendConfig.renderText as (args: { node: { attrs: Record<string, string> } }) => string
it('renders label in double-brace syntax', () => {
expect(renderText({ node: { attrs: { label: 'question', id: 'q1' } } })).toBe('{{question}}')
})
it('falls back to id when label is missing', () => {
expect(renderText({ node: { attrs: { id: 'chat_history' } } })).toBe('{{chat_history}}')
})
})
describe('markdownTokenizer', () => {
const tokenizer = extendConfig.markdownTokenizer as {
name: string
level: string
start: string
tokenize: (src: string) => { type: string; raw: string; label: string } | undefined
}
it('has correct name and level', () => {
expect(tokenizer.name).toBe('mention')
expect(tokenizer.level).toBe('inline')
expect(tokenizer.start).toBe('{{')
})
it('tokenizes {{variable}} syntax', () => {
const result = tokenizer.tokenize('{{question}} rest')
expect(result).toEqual({ type: 'mention', raw: '{{question}}', label: 'question' })
})
it('trims whitespace in label', () => {
const result = tokenizer.tokenize('{{ chat_history }}')
expect(result).toEqual({ type: 'mention', raw: '{{ chat_history }}', label: 'chat_history' })
})
it('returns undefined for non-matching input', () => {
expect(tokenizer.tokenize('plain text')).toBeUndefined()
})
it('returns undefined for single braces', () => {
expect(tokenizer.tokenize('{question}')).toBeUndefined()
})
it('does not match nested braces', () => {
expect(tokenizer.tokenize('{{outer{{inner}}}}')).toBeUndefined()
})
})
describe('parseMarkdown', () => {
const parseMarkdown = extendConfig.parseMarkdown as (token: { label: string }) => {
type: string
attrs: { id: string; label: string }
}
it('converts token to mention node attrs', () => {
expect(parseMarkdown({ label: 'question' })).toEqual({
type: 'mention',
attrs: { id: 'question', label: 'question' }
})
})
})
describe('renderMarkdown', () => {
const renderMarkdown = extendConfig.renderMarkdown as (node: { attrs?: Record<string, string> }) => string
it('renders label in double-brace syntax', () => {
expect(renderMarkdown({ attrs: { label: 'question', id: 'q1' } })).toBe('{{question}}')
})
it('falls back to id when label is missing', () => {
expect(renderMarkdown({ attrs: { id: 'chat_history' } })).toBe('{{chat_history}}')
})
it('handles undefined attrs gracefully', () => {
expect(renderMarkdown({})).toBe('{{undefined}}')
})
})
describe('addPasteRules', () => {
// addPasteRules is a function bound to `this` in the extension context
const addPasteRules = extendConfig.addPasteRules as () => PasteRule[]
it('returns an array with one PasteRule', () => {
const rules = addPasteRules.call({ name: 'mention' })
expect(rules).toHaveLength(1)
expect(rules[0]).toBeInstanceOf(PasteRule)
})
it('paste rule has a global regex for {{...}} pattern', () => {
const rules = addPasteRules.call({ name: 'mention' })
expect(rules[0].find).toEqual(/\{\{([^{}]+)\}\}/g)
})
})
describe('markdownTokenName', () => {
it('is "mention"', () => {
expect(extendConfig.markdownTokenName).toBe('mention')
})
})
})
@@ -0,0 +1,81 @@
/**
* Custom TipTap Mention extension for `{{ variable }}` syntax.
*
* Port of packages/ui/src/utils/customMention.js to TypeScript.
* Includes markdown tokenizer/parser/renderer for @tiptap/markdown integration.
*/
import { type JSONContent, type MarkdownToken, PasteRule } from '@tiptap/core'
import Mention from '@tiptap/extension-mention'
export const CustomMention = Mention.extend({
/**
* Render mention nodes as `{{label}}` in plain text output.
*/
renderText({ node }) {
return `{{${node.attrs.label ?? node.attrs.id}}}`
},
// ── @tiptap/markdown integration ─────────────────────────────────────────
// These properties are read by @tiptap/markdown's MarkdownManager via
// getExtensionField() to enable markdown round-tripping of {{variable}} syntax.
// Ported from packages/ui/src/utils/customMention.js.
/** Token name for the custom markdown tokenizer. */
markdownTokenName: 'mention',
/** Custom MarkedJS tokenizer to recognize {{...}} syntax in markdown input. */
markdownTokenizer: {
name: 'mention',
level: 'inline',
start: '{{',
tokenize(src: string) {
const match = src.match(/^\{\{([^{}]+)\}\}/)
if (match) {
return {
type: 'mention',
raw: match[0],
label: match[1].trim()
}
}
return undefined
}
},
/** Parse a markdown mention token into a TipTap mention node. */
parseMarkdown(token: MarkdownToken) {
return {
type: 'mention',
attrs: {
id: token.label,
label: token.label
}
}
},
/** Serialize a mention node back to {{label}} in markdown output. */
renderMarkdown(node: JSONContent) {
return `{{${node.attrs?.label ?? node.attrs?.id}}}`
},
/**
* Paste rule: auto-convert `{{variable}}` text into mention nodes on paste.
*/
addPasteRules() {
return [
new PasteRule({
find: /\{\{([^{}]+)\}\}/g,
handler: ({ match, chain, range }) => {
const label = match[1].trim()
if (label) {
chain()
.deleteRange(range)
.insertContentAt(range.from, {
type: this.name,
attrs: { id: label, label }
})
}
}
})
]
}
})
@@ -1,2 +1,3 @@
// Domain-free utility functions safe for use in any layer (including atoms)
export { CustomMention } from './customMention'
export { getDefaultValueForType } from './inputDefaults'
@@ -9,3 +9,6 @@ export { evaluateFieldVisibility, evaluateParamVisibility, stripHiddenFieldValue
// Dynamic output anchor utilities
export { buildDynamicOutputAnchors, parseOutputHandleIndex } from './dynamicOutputAnchors'
// Variable utilities
export { getUpstreamNodes } from './variableUtils'
@@ -0,0 +1,178 @@
import { extractVariables, getUpstreamNodes } from './variableUtils'
// ── extractVariables ────────────────────────────────────────────────────────
describe('extractVariables', () => {
it('returns empty array for empty string', () => {
expect(extractVariables('')).toEqual([])
})
it('returns empty array for text without variables', () => {
expect(extractVariables('Hello world')).toEqual([])
})
it('extracts a single variable', () => {
expect(extractVariables('Hello {{question}}')).toEqual(['question'])
})
it('extracts multiple variables', () => {
expect(extractVariables('{{question}} and {{chat_history}}')).toEqual(['question', 'chat_history'])
})
it('extracts node output references', () => {
expect(extractVariables('Result: {{node1.data.instance}}')).toEqual(['node1.data.instance'])
})
it('extracts flow state variables', () => {
expect(extractVariables('Count is {{$flow.state.count}}')).toEqual(['$flow.state.count'])
})
it('trims whitespace inside braces', () => {
expect(extractVariables('{{ question }}')).toEqual(['question'])
})
it('does not match single braces', () => {
expect(extractVariables('{notAVariable}')).toEqual([])
})
it('skips JSON-like content with colons', () => {
expect(extractVariables('{{"key": "value"}}')).toEqual([])
expect(extractVariables('{{"name": "test"}} and {{question}}')).toEqual(['question'])
})
it('does not match nested braces', () => {
expect(extractVariables('{{{nested}}}')).toEqual(['nested'])
})
it('does not match unclosed braces', () => {
expect(extractVariables('{{unclosed')).toEqual([])
})
it('handles mixed text and variables', () => {
expect(extractVariables('Say {{question}} to {{node1.data.instance}} please')).toEqual(['question', 'node1.data.instance'])
})
})
// ── getUpstreamNodes ────────────────────────────────────────────────────────
describe('getUpstreamNodes', () => {
const makeNode = (id: string, overrides?: { name?: string; parentNode?: string }) =>
({
id,
type: 'customNode',
position: { x: 0, y: 0 },
data: { id, name: overrides?.name ?? id, label: id },
...(overrides?.parentNode ? { parentNode: overrides.parentNode } : {})
} as {
id: string
type: string
position: { x: number; y: number }
data: { id: string; name: string; label: string }
parentNode?: string
})
const makeEdge = (source: string, target: string) => ({
id: `${source}-${target}`,
source,
target,
type: 'default'
})
it('returns empty array when no edges target the node', () => {
const nodes = [makeNode('a'), makeNode('b')]
const edges = [makeEdge('a', 'b')]
expect(getUpstreamNodes('a', nodes, edges)).toEqual([])
})
it('returns direct upstream nodes', () => {
const nodes = [makeNode('a'), makeNode('b'), makeNode('c')]
const edges = [makeEdge('a', 'b'), makeEdge('b', 'c')]
const upstream = getUpstreamNodes('b', nodes, edges)
expect(upstream).toHaveLength(1)
expect(upstream[0].id).toBe('a')
})
it('returns multiple upstream nodes', () => {
const nodes = [makeNode('a'), makeNode('b'), makeNode('c')]
const edges = [makeEdge('a', 'c'), makeEdge('b', 'c')]
const upstream = getUpstreamNodes('c', nodes, edges)
expect(upstream).toHaveLength(2)
expect(upstream.map((n) => n.id).sort()).toEqual(['a', 'b'])
})
it('does not return downstream nodes', () => {
const nodes = [makeNode('a'), makeNode('b'), makeNode('c')]
const edges = [makeEdge('a', 'b'), makeEdge('b', 'c')]
const upstream = getUpstreamNodes('a', nodes, edges)
expect(upstream).toHaveLength(0)
})
it('returns empty array when edges array is empty', () => {
const nodes = [makeNode('a')]
expect(getUpstreamNodes('a', nodes, [])).toEqual([])
})
it('recursively collects the full ancestor chain', () => {
// a → b → c → d: querying from d should return [a, b, c]
const nodes = [makeNode('a'), makeNode('b'), makeNode('c'), makeNode('d')]
const edges = [makeEdge('a', 'b'), makeEdge('b', 'c'), makeEdge('c', 'd')]
const upstream = getUpstreamNodes('d', nodes, edges)
expect(upstream.map((n) => n.id).sort()).toEqual(['a', 'b', 'c'])
})
it('handles diamond-shaped graphs without duplicates', () => {
// a
// / \
// b c
// \ /
// d
const nodes = [makeNode('a'), makeNode('b'), makeNode('c'), makeNode('d')]
const edges = [makeEdge('a', 'b'), makeEdge('a', 'c'), makeEdge('b', 'd'), makeEdge('c', 'd')]
const upstream = getUpstreamNodes('d', nodes, edges)
expect(upstream.map((n) => n.id).sort()).toEqual(['a', 'b', 'c'])
})
it('excludes startAgentflow by default', () => {
const nodes = [makeNode('start_0', { name: 'startAgentflow' }), makeNode('agent_0')]
const edges = [makeEdge('start_0', 'agent_0')]
const upstream = getUpstreamNodes('agent_0', nodes, edges)
expect(upstream).toHaveLength(0)
})
it('includes startAgentflow when includeStart is true', () => {
const nodes = [makeNode('start_0', { name: 'startAgentflow' }), makeNode('agent_0')]
const edges = [makeEdge('start_0', 'agent_0')]
const upstream = getUpstreamNodes('agent_0', nodes, edges, true)
expect(upstream).toHaveLength(1)
expect(upstream[0].id).toBe('start_0')
})
it('excludes startAgentflow but still collects nodes behind it', () => {
// In practice startAgentflow is the root, but this tests the exclusion logic:
// llm_0 → start_0 (excluded) → agent_0
const nodes = [makeNode('llm_0'), makeNode('start_0', { name: 'startAgentflow' }), makeNode('agent_0')]
const edges = [makeEdge('llm_0', 'start_0'), makeEdge('start_0', 'agent_0')]
const upstream = getUpstreamNodes('agent_0', nodes, edges)
// start_0 is excluded, but llm_0 should NOT be collected because
// we skip start_0 entirely (don't recurse through excluded nodes)
expect(upstream).toHaveLength(0)
})
it('traverses parentNode for nodes inside iteration groups', () => {
// group_0 contains child_0 (via parentNode). outer_0 → group_0.
// Querying from child_0 should find outer_0 via the parentNode traversal.
const nodes = [makeNode('outer_0'), makeNode('group_0'), makeNode('child_0', { parentNode: 'group_0' })]
const edges = [makeEdge('outer_0', 'group_0')]
const upstream = getUpstreamNodes('child_0', nodes, edges)
expect(upstream.map((n) => n.id)).toContain('outer_0')
})
it('handles cycles without infinite loop', () => {
// a → b → a (cycle)
const nodes = [makeNode('a'), makeNode('b')]
const edges = [makeEdge('a', 'b'), makeEdge('b', 'a')]
const upstream = getUpstreamNodes('a', nodes, edges)
expect(upstream).toHaveLength(1)
expect(upstream[0].id).toBe('b')
})
})
@@ -0,0 +1,74 @@
import type { FlowEdge, FlowNode } from '@/core/types'
/** Regex that matches `{{variablePath}}` tokens in text. Use with `g` flag. */
export const VARIABLE_REGEX = /\{\{([^{}]+)\}\}/g
/**
* Extract all variable paths from a string containing `{{variable}}` tokens.
*
* @example
* extractVariables('Hello {{question}}, see {{node1.data.instance}}')
* // => ['question', 'node1.data.instance']
*/
export function extractVariables(text: string): string[] {
const matches: string[] = []
let match: RegExpExecArray | null
// Reset lastIndex for safety (global regex)
VARIABLE_REGEX.lastIndex = 0
while ((match = VARIABLE_REGEX.exec(text)) !== null) {
const inside = match[1].trim()
// Skip JSON-like content (e.g. {{"key": "value"}})
if (!inside.includes(':')) {
matches.push(inside)
}
}
return matches
}
/**
* Recursively walk edges backward from `nodeId` to collect **all** ancestor nodes
* in the AgentFlow V2 graph. Excludes `startAgentflow` by default (its state
* variables are handled separately by `useAvailableVariables`). Also traverses
* the `parentNode` property for nodes inside iteration groups.
*
* Matches the `collectAgentFlowV2ParentNodes` logic in
* packages/ui/src/utils/genericHelper.js:655-672.
*
* @param includeStart When true, include `startAgentflow` in the results.
*/
export function getUpstreamNodes(nodeId: string, nodes: FlowNode[], edges: FlowEdge[], includeStart = false): FlowNode[] {
const collected = new Set<string>()
// Never include the queried node itself (prevents self-reference in cycles)
collected.add(nodeId)
function collect(targetId: string) {
// In AgentFlow V2, targetHandle === targetNodeId for node-level connections
const inputEdges = edges.filter((e) => e.target === targetId)
for (const edge of inputEdges) {
if (collected.has(edge.source)) continue
const parentNode = nodes.find((n) => n.id === edge.source)
if (!parentNode) continue
// Exclude startAgentflow unless explicitly requested
if (parentNode.data.name === 'startAgentflow' && !includeStart) {
continue
}
collected.add(parentNode.id)
// Recurse to collect the full ancestor chain
collect(parentNode.id)
}
}
collect(nodeId)
// Also traverse the parentNode property (for nodes inside iteration groups)
const targetNode = nodes.find((n) => n.id === nodeId)
if (targetNode?.parentNode) {
collect(targetNode.parentNode)
}
return nodes.filter((n) => n.id !== nodeId && collected.has(n.id))
}
@@ -138,12 +138,28 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog
useEffect(() => {
if (dialogProps.inputParams) {
const initialValues = dialogProps.data?.inputs || {}
// Merge param defaults into inputValues so that fields with show/hide
// conditions referencing other fields with defaults evaluate correctly
// on first load (e.g., llmEnableMemory defaults to true).
const storedValues = dialogProps.data?.inputs || {}
const initialValues: Record<string, unknown> = { ...storedValues }
for (const param of dialogProps.inputParams) {
if (param.default !== undefined && !(param.name in initialValues)) {
initialValues[param.name] = param.default
}
}
const evaluatedParams = evaluateFieldVisibility(dialogProps.inputParams, initialValues)
setInputParams(evaluatedParams)
setArrayItemParameters(computeArrayItemParameters(dialogProps.inputParams, initialValues))
}
if (dialogProps.data) {
// Store data with defaults merged into inputValues so that subsequent
// onCustomDataChange calls (e.g., triggered by VariableInput's onUpdate
// on mount) preserve the defaults for visibility evaluation.
if (dialogProps.data) {
setData({ ...dialogProps.data, inputs: initialValues })
if (dialogProps.data.label) setNodeName(dialogProps.data.label)
}
} else if (dialogProps.data) {
setData(dialogProps.data)
if (dialogProps.data.label) setNodeName(dialogProps.data.label)
}
@@ -38,33 +38,51 @@ describe('useAvailableVariables', () => {
mockState.edges = []
})
it('always returns global variables (question, chat_history, file_attachment)', () => {
it('always returns global variables (chat context + flow variables)', () => {
const { result } = renderHook(() => useAvailableVariables('node_0'))
const labels = result.current.map((i) => i.label)
// Chat Context
expect(labels).toContain('question')
expect(labels).toContain('chat_history')
expect(labels).toContain('current_date_time')
expect(labels).toContain('runtime_messages_length')
expect(labels).toContain('loop_count')
expect(labels).toContain('file_attachment')
expect(result.current).toHaveLength(3)
// Flow Variables
expect(labels).toContain('$flow.sessionId')
expect(labels).toContain('$flow.chatId')
expect(labels).toContain('$flow.chatflowId')
expect(result.current).toHaveLength(9)
})
it('returns upstream node outputs based on edges', () => {
mockState.nodes = [makeNode('llm_0', 'llmAgentflow'), makeNode('agent_0', 'agentAgentflow')]
mockState.edges = [{ source: 'llm_0', target: 'agent_0' }]
const { result } = renderHook(() => useAvailableVariables('agent_0'))
const nodeOutputs = result.current.filter((i) => i.category === 'Node Outputs')
expect(nodeOutputs).toHaveLength(1)
expect(nodeOutputs[0].value).toBe('{{llm_0.data.instance}}')
expect(nodeOutputs[0].label).toBe('llmAgentflow')
})
it('excludes startAgentflow from node outputs', () => {
mockState.nodes = [makeNode('start_0', 'startAgentflow'), makeNode('agent_0', 'agentAgentflow')]
mockState.edges = [{ source: 'start_0', target: 'agent_0' }]
const { result } = renderHook(() => useAvailableVariables('agent_0'))
const nodeOutputs = result.current.filter((i) => i.category === 'Node Outputs')
expect(nodeOutputs).toHaveLength(1)
expect(nodeOutputs[0].value).toBe('{{start_0.data.instance}}')
expect(nodeOutputs[0].label).toBe('startAgentflow')
expect(nodeOutputs).toHaveLength(0)
})
it('does not return downstream node outputs', () => {
mockState.nodes = [makeNode('start_0', 'startAgentflow'), makeNode('agent_0', 'agentAgentflow')]
mockState.edges = [{ source: 'start_0', target: 'agent_0' }]
mockState.nodes = [makeNode('llm_0', 'llmAgentflow'), makeNode('agent_0', 'agentAgentflow')]
mockState.edges = [{ source: 'llm_0', target: 'agent_0' }]
const { result } = renderHook(() => useAvailableVariables('start_0'))
const { result } = renderHook(() => useAvailableVariables('llm_0'))
const nodeOutputs = result.current.filter((i) => i.category === 'Node Outputs')
expect(nodeOutputs).toHaveLength(0)
@@ -1,10 +1,11 @@
import { useMemo } from 'react'
import type { VariableItem } from '@/atoms/SelectVariable'
import type { FlowEdge, FlowNode } from '@/core/types'
import type { VariableItem } from '@/atoms/VariablePicker'
import { getAgentflowIcon } from '@/core/node-config/nodeIconUtils'
import { getUpstreamNodes } from '@/core/utils/variableUtils'
import { useAgentflowContext } from '@/infrastructure/store'
// ── Static global variables (matches original SelectVariable.jsx) ───────────
// ── Static global variables (matches original suggestionOption.js) ───────────
const GLOBAL_VARIABLES: VariableItem[] = [
{ label: 'question', description: "User's question from chatbox", category: 'Chat Context', value: '{{question}}' },
@@ -14,41 +15,53 @@ const GLOBAL_VARIABLES: VariableItem[] = [
category: 'Chat Context',
value: '{{chat_history}}'
},
{
label: 'current_date_time',
description: 'Current date and time',
category: 'Chat Context',
value: '{{current_date_time}}'
},
{
label: 'runtime_messages_length',
description: 'Total messages between LLM and Agent',
category: 'Chat Context',
value: '{{runtime_messages_length}}'
},
{
label: 'loop_count',
description: 'Current loop count',
category: 'Chat Context',
value: '{{loop_count}}'
},
{
label: 'file_attachment',
description: 'Files uploaded from the chat when Full File Upload is enabled on the Configuration',
description: 'Files uploaded from the chat',
category: 'Chat Context',
value: '{{file_attachment}}'
},
{ label: '$flow.sessionId', description: 'Current session ID', category: 'Flow Variables', value: '{{$flow.sessionId}}' },
{ label: '$flow.chatId', description: 'Current chat ID', category: 'Flow Variables', value: '{{$flow.chatId}}' },
{
label: '$flow.chatflowId',
description: 'Current chatflow ID',
category: 'Flow Variables',
value: '{{$flow.chatflowId}}'
}
]
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Walk edges backward from `nodeId` to collect all direct upstream source nodes.
*/
function getUpstreamNodes(nodeId: string, nodes: FlowNode[], edges: FlowEdge[]): FlowNode[] {
const sourceIds = new Set<string>()
for (const edge of edges) {
if (edge.target === nodeId) {
sourceIds.add(edge.source)
}
}
return nodes.filter((n) => sourceIds.has(n.id))
}
// ── Hook ─────────────────────────────────────────────────────────────────────
/**
* Returns the list of variable items available for a given node.
*
* Matches the original SelectVariable.jsx behaviour:
* - Global variables: question, chat_history, file_attachment
* Matches the original suggestionOption.js behaviour:
* - Chat context: question, chat_history, current_date_time, runtime_messages_length, loop_count, file_attachment
* - Flow variables: $flow.sessionId, $flow.chatId, $flow.chatflowId
* - Upstream node outputs (from edges)
* - Flow state variables (from startAgentflow node's startState)
*
* Lives in the features layer so it can read from AgentflowContext.
* The returned items are passed to the SelectVariable atom via props.
* The returned items are passed to the VariablePicker atom via props.
*/
export function useAvailableVariables(nodeId: string): VariableItem[] {
const { state } = useAgentflowContext()
@@ -67,11 +80,14 @@ export function useAvailableVariables(nodeId: string): VariableItem[] {
node.data.label ??
node.data.id
const agentflowIcon = getAgentflowIcon(node.data.name)
items.push({
label: displayName,
description: `Output from ${node.data.label ?? node.data.name}`,
category: 'Node Outputs',
value: `{{${node.id}.data.instance}}`
value: `{{${node.id}.data.instance}}`,
icon: agentflowIcon?.icon,
iconColor: agentflowIcon?.color
})
}
+20 -326
View File
@@ -132,18 +132,27 @@ importers:
'@tabler/icons-react':
specifier: ^3.7.0
version: 3.31.0([email protected])
'@tiptap/core':
specifier: ^3.20.4
version: 3.20.4(@tiptap/[email protected])
'@tiptap/extension-code-block-lowlight':
specifier: ^3.4.3
version: 3.4.3(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/[email protected](@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)([email protected])([email protected])
specifier: ^3.20.4
version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/[email protected](@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)([email protected])([email protected])
'@tiptap/extension-mention':
specifier: ^3.20.4
version: 3.20.4(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])(@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))
'@tiptap/extension-placeholder':
specifier: ^2.11.5
version: 2.12.0(@tiptap/[email protected](@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)
specifier: ^3.20.4
version: 3.20.4(@tiptap/[email protected](@tiptap/[email protected](@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
'@tiptap/pm':
specifier: ^3.20.4
version: 3.20.4
'@tiptap/react':
specifier: ^2.11.5
version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)([email protected]([email protected]))([email protected])
specifier: ^3.20.4
version: 3.20.4(@floating-ui/[email protected])(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(@types/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
'@tiptap/starter-kit':
specifier: ^2.11.5
version: 2.12.0
specifier: ^3.20.4
version: 3.20.4
'@uiw/codemirror-theme-sublime':
specifier: ^4.21.0
version: 4.21.24(@codemirror/[email protected])(@codemirror/[email protected])(@codemirror/[email protected])
@@ -180,6 +189,9 @@ importers:
reactflow:
specifier: ^11.5.0
version: 11.10.4(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
tippy.js:
specifier: ^6.3.7
version: 6.3.7
uuid:
specifier: ^10.0.0
version: 10.0.0
@@ -8289,53 +8301,27 @@ packages:
peerDependencies:
'@testing-library/dom': '>=7.21.4'
'@tiptap/[email protected]':
resolution: {integrity: sha512-3qX8oGVKFFZzQ0vit+ZolR6AJIATBzmEmjAA0llFhWk4vf3v64p1YcXcJsOBsr5scizJu5L6RYWEFatFwqckRg==}
peerDependencies:
'@tiptap/pm': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==}
peerDependencies:
'@tiptap/pm': ^3.20.4
'@tiptap/[email protected]':
resolution: {integrity: sha512-XUC2A77YAPMJS2SqZ2S62IGcUH8gZ7cdhoWlYQb1pR4ZzXFByeKDJPxfYeAePSiuI01YGrlzgY2c6Ncx/DtO0A==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-9sskyyhYj2oKat//lyZVXCp9YrPt4oJAZnGHYWXS0xlskjsLElrfKKlM4vpbhGss3VrhQRoEGqWLnIaJYPF1zw==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/[email protected]':
resolution: {integrity: sha512-lAUtoLDLRc5ofD2I9MFY6MQ7d1qBLLqS1rvpwaPjOaoQb/GPVnaHj9qXYG0SY9K3erMtto48bMFpAcscjZHzZQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-Md7/mNAeJCY+VLJc8JRGI+8XkVPKiOGB1NgqQPdh3aYtxXQDChQOZoJEQl6TuudDxZ85bLZB67NjZlx3jo8/0g==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/[email protected]':
resolution: {integrity: sha512-DYijoE0igV0Oi+ZppFsp2UrQsM/4HZtmmpD78BJM9zfCbd1YvAUIxmzmXr8uqU18OHd1uQy+/zvuNoUNYyw67g==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-EXywPlI8wjPcAb8ozymgVhjtMjFrnhtoyNTy8ZcObdpUi5CdO9j892Y7aPbKe5hLhlDpvJk7rMfir4FFKEmfng==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/pm': ^3.20.4
'@tiptap/[email protected]':
resolution: {integrity: sha512-YTCjztB8MaIpwyxFYr81H4+LdKCq1VlaSXQyrPdB44mVdhhRqc46BYQb8/B//XE3UIu3X2QWFjwrqRlUq6vUiw==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-1RTGrur1EKoxfnLZ3M6xeNj8GITAz74jH2DHGcjLsd2Xr7Q7BozGaIq6GkkvKguMwbI1zCOxTHFCpUETXAIQQA==}
peerDependencies:
@@ -8350,64 +8336,27 @@ packages:
highlight.js: ^11
lowlight: ^2 || ^3
'@tiptap/[email protected]':
resolution: {integrity: sha512-d3vEhv2cLDdWL2unqRkF0LkGEDEm/pJCWZwSjqzYFe3vrTEsTExorpwZoVV8i9uX/DPG4AdM+mf3hLK9L5FZoA==}
peerDependencies:
'@tiptap/core': ^3.4.3
'@tiptap/extension-code-block': ^3.4.3
'@tiptap/pm': ^3.4.3
highlight.js: ^11
lowlight: ^2 || ^3
'@tiptap/[email protected]':
resolution: {integrity: sha512-1D7cYAjgxEFHdfC/35Ooi4GqWKB5sszbW8iI7N16XILNln26xb0d5KflXqYrwr9CN/ZnZoCl2o6YsP7xEObcZA==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-Zlw3FrXTy01+o1yISeX/LC+iJeHA+ym602bMXGmtA6lyl7QSOSO7WExweJ6xeJGhbCjldwT5al6fkRAs8iGJZg==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/pm': ^3.20.4
'@tiptap/[email protected]':
resolution: {integrity: sha512-R7RaS+hJeHFim7alImQ9L9CSWSMjWXvz0Ote568x9ea5gdBGUYW8PcH+5a91lh8e1XGYWBM12a8oJZRyxg/tQA==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-7j8Hi964bH1SZ9oLdZC1fkqWz27mliSDV7M8lmL/M14+Qw42D/VOAKS4Aw9OCFtHMlTsjLR6qsoVxL8Lpkt6NA==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/[email protected]':
resolution: {integrity: sha512-sA1Q+mxDIv0Y3qQTBkYGwknNbDcGFiJ/fyAFholXpqbrcRx3GavwR/o0chBdsJZlFht0x7AWGwUYWvIo7wYilA==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-zF1CIFVLt8MfSpWWnPwtGyxPOsT0xYM2qJKcXf2yZcTG37wDKmUi6heG53vGigIavbQlLaAFvs+1mNdOu2x/0A==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/[email protected]':
resolution: {integrity: sha512-zcZSOXFj+7LVnmdPWTfKr5AoxYIzFPFlLJe35AdTQC5IhkljLn1Exct8I30ZREojX/00hKYsO7JJmePS6TEVlQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-TgMwvZ8myXYdmd6bUV7qkpZXv7ZUiSmX/8eo+iPEzYo2CnDLAGvDKgC50nfq/g87SDvfBgPuAiBfFvsMQQWaTw==}
peerDependencies:
'@tiptap/extensions': ^3.20.4
'@tiptap/[email protected]':
resolution: {integrity: sha512-BYpyZx/56KCDksWuJJbhki/uNgt9sACuSSZFH5AN1yS1ISD+EzIxqf6Pzzv8QCoNJ+KcRNVaZsOlOFaJGoyzag==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-AaPTFhoO8DBIElJyd/RTVJjkctvJuL+GHURX0npbtTxXq5HXbebVwf2ARNR7jMd/GThsmBaNJiGxZg4A2oeDqQ==}
peerDependencies:
@@ -8415,60 +8364,27 @@ packages:
'@tiptap/core': ^3.20.4
'@tiptap/pm': ^3.20.4
'@tiptap/[email protected]':
resolution: {integrity: sha512-k8ji5v9YKn7bNjo8UtI9hEfXfl4tKUp1hpJOEmUxGJQa3LIwrwSbReupUTnHszGQelzxikS/l1xO9P0TIGwRoA==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-JJ6f1iQ1e0s4kISgq55U3UYGwWV/N9f0PYMtB6e3L+SBQjXnywaLK0g6vfN6IvTCC2vdIuqeSOX8VlSO97sJLw==}
peerDependencies:
'@tiptap/extensions': ^3.20.4
'@tiptap/[email protected]':
resolution: {integrity: sha512-08MNS2PK5DzdnAfqXn4krmJ/xebKmWpRpYqqN5EM8AvetYKlAJyTVSpo0ZUeGbZ3EZiPm9djgSnrLqpFUDjRCg==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-gJbq58d8zB1gzyqVEopowej5CpW4/Fpg6oGJvlZxaCukqd0gJRWGC89K+jE62YA1Td4sfcKrekKvN7jm2y/ZUg==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/[email protected]':
resolution: {integrity: sha512-9DfES4Wd5TX1foI70N9sAL+35NN1UHrtzDYN2+dTHupnmKir9RaMXyZcbkUb4aDVzYrGxIqxJzHBVkquKIlTrw==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-xsnkmTGggJc5P2iCwS1lv8KFG31xC/GNPJKoi/3UH67j/lKDhA3AdtshsLeyv2FKtTtYDb8oV0IqzHB1MM6a7w==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/[email protected]':
resolution: {integrity: sha512-+B9CAf2BFURC6mQiM1OQtahVTzdEOEgT/UUNlRZkeeBc0K5of3dr6UdBqaoaMAefja3jx5PqiQ7mhUBAjSt6AA==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-Vi2+6RIehDSpoJn/7PDuOieUj7W7WrEb4wBxK9TG8PDscihR0mehhhzm/K2xhH4TN48iPJGRsjDFrFjTbXmcnw==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-y6joCi49haAA0bo3EGUY+dWUMHH1GPUc84hxrBY/0pMs+Bn+kQ1+DQJErZDTWGJrlHPWU/yekBZT72SNdp0DNA==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/pm': ^3.20.4
'@tiptap/[email protected]':
resolution: {integrity: sha512-JKcXK3LmEsmxNzEq5e06rPUGMRLUxmJ2mYtBY4NlJ6yLM9XMDljtgeTnWT0ySLYmfINSFTkX4S7WIRbpl9l4pw==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-4ZqiWr7cmqPFux8tj1ZLiYytyWf343IvQemNX6AvVWvscrJcrfj3YX4Le2BA0RW3A3M6RpLQXXozuF8vxYFDeQ==}
peerDependencies:
@@ -8480,11 +8396,6 @@ packages:
'@tiptap/core': ^3.20.4
'@tiptap/pm': ^3.20.4
'@tiptap/[email protected]':
resolution: {integrity: sha512-4YwZooC8HP+gPxs6YrkB1ayggyYbgVvJx/rWBT6lKSW2MVVg8QXi1zAcSI3MhIhHmqDysXXFPL8JURlbeGjaFA==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-QoTc5RACXaZF+vIIBBxjGO7D0oWFUDgBKJCpvUZ0CoGGKosnfe4a9I5THFyLj4201cf0oUqgf1oZhTqETGxlVw==}
peerDependencies:
@@ -8508,57 +8419,26 @@ packages:
'@tiptap/pm': ^3.20.4
'@tiptap/suggestion': ^3.20.4
'@tiptap/[email protected]':
resolution: {integrity: sha512-1ys0e/oqk09oXxrB1WzAx5EntK/QreObG/V1yhgihGm429fxHMsxzIYN6dKAYxx0YOPQG7qEZRrrPuWU70Ms7g==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-3budNL8BgBon3TcXZ4hjT0YpFvx1Ka3uSIECKDxHgES+OQcR+6cagxSb60gFEccf3Dr0PIwcVTY6g14lC1qKRQ==}
peerDependencies:
'@tiptap/extension-list': ^3.20.4
'@tiptap/[email protected]':
resolution: {integrity: sha512-QNK5cgewCunWFxpLlbvvoO1rrLgEtNKxiY79fctP9toV+e59R+1i1Q9lXC1O5mOfDgVxCb6uFDMsqmKhFjpPog==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-lm6fOScWuZAF/Sfp97igUwFd3L1QHIVLAWP5NVdh0DTLrEIt4rMBmsww+yOpMQRhvz2uTgMbMXynrimhzi/QVw==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/[email protected]':
resolution: {integrity: sha512-K7irDox4P+NLAMjVrJeG72f0sulsCRYpx1Cy4gxKCdi1LTivj5VkXa6MXmi42KTCwBu3pWajBctYIOAES1FTAA==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-GB0KWtqm83YHG8cnqBLijvUBm+xvLfQHDfFRRH2fb3EzH3eIsM9jKRC31ADT27RSV1zVpHMFGcP3/pWpdrN1Lw==}
peerDependencies:
'@tiptap/extensions': ^3.20.4
'@tiptap/[email protected]':
resolution: {integrity: sha512-nBaa5YtBsLJPZFfSs36sBz4Zgi/c8b3MsmS/Az8uXaHb0R9yPewOVUMDIQbxMct8SXUlIo9VtKlOL+mVJ3Nkpw==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-It1Px9uDGTsVqyyg6cy7DigLoenljpQwqdI0jssM7QclZrHnsrye9fZxBBiiuCzzV1305MxKgHvratkHwqmVNA==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/[email protected]':
resolution: {integrity: sha512-Pxwt23ZlvbQUahV0PvHy8Ej6IAuKR1FvHobUvwP3T8AiY7hob66fWRe7tQbESzSAzm5Vv2xkvyHeU8vekMTezA==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-0ytN9V1tZYTXdiYDQg4FB2SQ56JAJC9r/65snefb9ztl+gZzDrIvih7CflHs1ic9PgyjexfMLeH+VzuMccNyZw==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-jchJcBZixDEO2J66Zx5dchsI2mA6IYsROqF8P1poxL4ienH7RVQRCTsBNnSfIeOtREKKWeOU/tEs5fcpvvGwIQ==}
peerDependencies:
@@ -8581,20 +8461,9 @@ packages:
'@tiptap/core': ^3.20.4
'@tiptap/pm': ^3.20.4
'@tiptap/[email protected]':
resolution: {integrity: sha512-TNzVwpeNzFfHAcYTOKqX9iU4fRxliyoZrCnERR+RRzeg7gWrXrCLubQt1WEx0sojMAfznshSL3M5HGsYjEbYwA==}
'@tiptap/[email protected]':
resolution: {integrity: sha512-rCHYSBToilBEuI6PtjziHDdRkABH/XqwJ7dG4Amn/SD3yGiZKYCiEApQlTUS2zZeo8DsLeuqqqB4vEOeD4OEPg==}
'@tiptap/[email protected]':
resolution: {integrity: sha512-D+PR+4kJO9h8AB/7XyQ/Anw8tqeS2ecv5QemBOCHi9JlMAjytauUrj6IfFBO9RbsCowlBjW5GnSpFhzpk2Gghg==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-1B8iWsHWwb5TeyVaUs8BRPzwWo4PsLQcl03urHaz0zTJ8DauopqvxzV3+lem1OkzRHn7wnrapDvwmIGoROCaQw==}
peerDependencies:
@@ -8605,9 +8474,6 @@ packages:
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-wlcEEtexd6u0gbR311/OFZnbtRWU97DUsY6/GsSQzN4rqZ7Ra6YbfHEN5Lutu+I/anomK8vKy8k9NyvfY5Hllg==}
'@tiptap/[email protected]':
resolution: {integrity: sha512-WcyK6hsTl8eBsQhQ+d9Sq8fYZKOYdL+D45MyH3hz583elXqJlW3h3JPFYb0o87gddGxn8Mm57OA/gA1zEdeDMw==}
@@ -30515,36 +30381,18 @@ snapshots:
dependencies:
'@testing-library/dom': 9.3.4
'@tiptap/[email protected](@tiptap/[email protected])':
dependencies:
'@tiptap/pm': 2.12.0
'@tiptap/[email protected](@tiptap/[email protected])':
dependencies:
'@tiptap/pm': 3.20.4
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/pm': 2.12.0
tippy.js: 6.3.7
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
dependencies:
'@floating-ui/dom': 1.6.3
@@ -30552,10 +30400,6 @@ snapshots:
'@tiptap/pm': 3.20.4
optional: true
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))':
dependencies:
'@tiptap/extension-list': 3.20.4(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
@@ -30568,60 +30412,23 @@ snapshots:
highlight.js: 11.11.1
lowlight: 3.3.0
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))(@tiptap/[email protected])([email protected])([email protected])':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/extension-code-block': 3.20.4(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
'@tiptap/pm': 2.12.0
highlight.js: 11.11.1
lowlight: 3.3.0
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/pm': 2.12.0
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/pm': 2.12.0
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/[email protected])
'@tiptap/pm': 3.20.4
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/pm': 2.12.0
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))':
dependencies:
'@tiptap/extensions': 3.20.4(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/pm': 2.12.0
tippy.js: 6.3.7
'@tiptap/[email protected](@floating-ui/[email protected])(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
dependencies:
'@floating-ui/dom': 1.6.3
@@ -30629,50 +30436,23 @@ snapshots:
'@tiptap/pm': 3.20.4
optional: true
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/pm': 2.12.0
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))':
dependencies:
'@tiptap/extensions': 3.20.4(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/pm': 2.12.0
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/pm': 2.12.0
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/[email protected])
'@tiptap/pm': 3.20.4
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/[email protected])
@@ -30683,10 +30463,6 @@ snapshots:
'@tiptap/pm': 3.20.4
linkifyjs: 4.3.2
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))':
dependencies:
'@tiptap/extension-list': 3.20.4(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
@@ -30706,47 +30482,22 @@ snapshots:
'@tiptap/pm': 3.20.4
'@tiptap/suggestion': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))':
dependencies:
'@tiptap/extension-list': 3.20.4(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/pm': 2.12.0
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))':
dependencies:
'@tiptap/extensions': 3.20.4(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/[email protected])
@@ -30766,27 +30517,6 @@ snapshots:
'@tiptap/pm': 3.20.4
marked: 17.0.4
'@tiptap/[email protected]':
dependencies:
prosemirror-changeset: 2.3.0
prosemirror-collab: 1.3.1
prosemirror-commands: 1.7.1
prosemirror-dropcursor: 1.8.2
prosemirror-gapcursor: 1.3.2
prosemirror-history: 1.4.1
prosemirror-inputrules: 1.5.0
prosemirror-keymap: 1.2.3
prosemirror-markdown: 1.13.2
prosemirror-menu: 1.2.5
prosemirror-model: 1.25.1
prosemirror-schema-basic: 1.2.4
prosemirror-schema-list: 1.5.1
prosemirror-state: 1.4.3
prosemirror-tables: 1.7.1
prosemirror-trailing-node: 3.0.0([email protected])([email protected])([email protected])
prosemirror-transform: 1.10.4
prosemirror-view: 1.39.2
'@tiptap/[email protected]':
dependencies:
prosemirror-changeset: 2.3.0
@@ -30808,18 +30538,6 @@ snapshots:
prosemirror-transform: 1.10.4
prosemirror-view: 1.39.2
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/extension-bubble-menu': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
'@tiptap/extension-floating-menu': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
'@tiptap/pm': 2.12.0
'@types/use-sync-external-store': 0.0.6
fast-deep-equal: 3.1.3
react: 18.2.0
react-dom: 18.2.0([email protected])
use-sync-external-store: 1.2.0([email protected])
'@tiptap/[email protected](@floating-ui/[email protected])(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])(@types/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/[email protected])
@@ -30837,30 +30555,6 @@ snapshots:
transitivePeerDependencies:
- '@floating-ui/dom'
'@tiptap/[email protected]':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/[email protected])
'@tiptap/extension-blockquote': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
'@tiptap/extension-bold': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
'@tiptap/extension-bullet-list': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
'@tiptap/extension-code': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
'@tiptap/extension-code-block': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
'@tiptap/extension-document': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
'@tiptap/extension-dropcursor': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
'@tiptap/extension-gapcursor': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
'@tiptap/extension-hard-break': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
'@tiptap/extension-heading': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
'@tiptap/extension-history': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
'@tiptap/extension-horizontal-rule': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
'@tiptap/extension-italic': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
'@tiptap/extension-list-item': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
'@tiptap/extension-ordered-list': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
'@tiptap/extension-paragraph': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
'@tiptap/extension-strike': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
'@tiptap/extension-text': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
'@tiptap/extension-text-style': 2.12.0(@tiptap/[email protected](@tiptap/[email protected]))
'@tiptap/pm': 2.12.0
'@tiptap/[email protected]':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/[email protected])