feat(agentflow): enhance typography and improve node palette drawer (#5806)

* feat(agentflow): enhance typography and improve node palette drawer functionality

- Add typography settings for h4, h5, and h6 in the theme configuration.
- Implement tests for typography to ensure consistency across light and dark modes.
- Refactor AddNodesDrawer to improve drag-and-drop functionality and integrate new NodeListItem component.
- Introduce useDrawerMaxHeight hook to dynamically calculate drawer height based on viewport.
- Clean up unused imports and optimize component structure for better performance.

* fix lint issues

* address review feeddback
This commit is contained in:
Jocelyn Lin
2026-02-20 16:47:48 -08:00
committed by GitHub
parent 3201257a82
commit 5c82c42e5f
5 changed files with 238 additions and 135 deletions
@@ -127,6 +127,32 @@ describe('createAgentflowTheme', () => {
})
})
describe('Typography', () => {
const theme = createAgentflowTheme(false)
it('should set h4 to 1rem / 600 weight', () => {
expect(theme.typography.h4.fontSize).toBe('1rem')
expect(theme.typography.h4.fontWeight).toBe(600)
})
it('should set h5 to 0.875rem / 600 weight', () => {
expect(theme.typography.h5.fontSize).toBe('0.875rem')
expect(theme.typography.h5.fontWeight).toBe(600)
})
it('should set h6 to 0.75rem / 500 weight', () => {
expect(theme.typography.h6.fontSize).toBe('0.75rem')
expect(theme.typography.h6.fontWeight).toBe(500)
})
it('should have same typography in both modes', () => {
const darkTheme = createAgentflowTheme(true)
expect(theme.typography.h4).toEqual(darkTheme.typography.h4)
expect(theme.typography.h5).toEqual(darkTheme.typography.h5)
expect(theme.typography.h6).toEqual(darkTheme.typography.h6)
})
})
describe('MUI Theme Integration', () => {
const theme = createAgentflowTheme(false)
@@ -34,6 +34,14 @@ export function createAgentflowTheme(isDarkMode: boolean): Theme {
main: tokens.colors.background.card[mode]
}
},
typography: {
h4: { fontSize: '1rem', fontWeight: 600 },
h5: { fontSize: '0.875rem', fontWeight: 600 },
h6: { fontSize: '0.75rem', fontWeight: 500 },
subtitle1: { fontSize: '0.875rem', fontWeight: 500 },
body1: { fontSize: '0.875rem', fontWeight: 400 },
body2: { fontSize: '0.75rem', fontWeight: 400 }
},
spacing: 8, // MUI's default base unit
shape: {
borderRadius: tokens.borderRadius.md
@@ -6,16 +6,11 @@ import {
AccordionDetails,
AccordionSummary,
Box,
Chip,
ClickAwayListener,
Divider,
Fade,
InputAdornment,
List,
ListItem,
ListItemAvatar,
ListItemButton,
ListItemText,
OutlinedInput,
Paper,
Popper,
@@ -26,12 +21,16 @@ import { useTheme } from '@mui/material/styles'
import { IconMinus, IconPlus, IconSearch, IconX } from '@tabler/icons-react'
import { MainCard } from '@/atoms'
import { AGENTFLOW_ICONS } from '@/core'
import { tokens } from '@/core/theme/tokens'
import type { NodeData } from '@/core/types'
import { useApiContext, useConfigContext } from '@/infrastructure/store'
import { useApiContext } from '@/infrastructure/store'
import { NodeListItem } from './NodeListItem'
import { debounce, groupNodesByCategory, searchNodes } from './search'
import { StyledFab } from './StyledFab'
import { useDrawerMaxHeight } from './useDrawerMaxHeight'
const Z_INDEX_DRAWER = 1000
export interface AddNodesDrawerProps {
/** Available nodes to display */
@@ -48,7 +47,6 @@ export interface AddNodesDrawerProps {
function AddNodesDrawerComponent({ nodes, onDragStart, onNodeClick }: AddNodesDrawerProps) {
const theme = useTheme()
const { apiBaseUrl } = useApiContext()
const { isDarkMode: _isDarkMode } = useConfigContext()
const [searchValue, setSearchValue] = useState('')
const [filteredNodes, setFilteredNodes] = useState<Record<string, NodeData[]>>({})
@@ -56,7 +54,9 @@ function AddNodesDrawerComponent({ nodes, onDragStart, onNodeClick }: AddNodesDr
const [categoryExpanded, setCategoryExpanded] = useState<Record<string, boolean>>({})
const anchorRef = useRef<HTMLButtonElement>(null)
const paperRef = useRef<HTMLDivElement>(null)
const prevOpen = useRef(open)
const drawerMaxHeight = useDrawerMaxHeight(open, paperRef)
// Group nodes by category
const groupNodes = useCallback((nodeList: NodeData[], expandAll = false) => {
@@ -119,8 +119,6 @@ function AddNodesDrawerComponent({ nodes, onDragStart, onNodeClick }: AddNodesDr
}
const handleDragStart = (event: React.DragEvent, node: NodeData) => {
event.dataTransfer.setData('application/reactflow', JSON.stringify(node))
event.dataTransfer.effectAllowed = 'move'
onDragStart?.(event, node)
}
@@ -128,15 +126,6 @@ function AddNodesDrawerComponent({ nodes, onDragStart, onNodeClick }: AddNodesDr
onNodeClick?.(node)
}
const renderIcon = (node: NodeData) => {
const foundIcon = AGENTFLOW_ICONS.find((icon) => icon.name === node.name)
if (foundIcon) {
const IconComponent = foundIcon.icon
return <IconComponent size={30} color={node.color} />
}
return null
}
// Initialize nodes on mount
useEffect(() => {
if (nodes.length > 0) {
@@ -165,42 +154,51 @@ function AddNodesDrawerComponent({ nodes, onDragStart, onNodeClick }: AddNodesDr
position: 'absolute',
left: 20,
top: 20,
zIndex: 1000
zIndex: Z_INDEX_DRAWER
}}
>
{open ? <IconMinus /> : <IconPlus />}
</StyledFab>
<Popper
placement='bottom-start'
placement='bottom-end'
open={open}
anchorEl={anchorRef.current}
role={undefined}
transition
disablePortal
modifiers={[
{
name: 'offset',
options: {
offset: [0, 14]
popperOptions={{
modifiers: [
{
name: 'offset',
options: {
offset: [-40, 14]
}
}
}
]}
sx={{ zIndex: 1000 }}
]
}}
sx={{ zIndex: Z_INDEX_DRAWER }}
>
{({ TransitionProps }) => (
<Fade {...TransitionProps} timeout={200}>
<Paper elevation={16}>
<Paper
ref={paperRef}
sx={{
display: 'flex',
flexDirection: 'column',
maxHeight: drawerMaxHeight,
overflow: 'hidden'
}}
>
<ClickAwayListener onClickAway={handleClose}>
<MainCard
border={false}
sx={{
boxShadow: theme.shadows[16],
width: 370,
maxWidth: '90vw'
}}
content={false}
boxShadow
shadow={theme.shadows[16]}
sx={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
<Box sx={{ p: 2 }}>
<Box sx={{ p: 2, flexShrink: 0 }}>
<Stack>
<Typography variant='h4'>Add Nodes</Typography>
</Stack>
@@ -243,7 +241,8 @@ function AddNodesDrawerComponent({ nodes, onDragStart, onNodeClick }: AddNodesDr
<Box
sx={{
maxHeight: 'calc(100vh - 300px)',
flex: 1,
minHeight: 0,
overflowY: 'auto',
overflowX: 'hidden'
}}
@@ -252,8 +251,9 @@ function AddNodesDrawerComponent({ nodes, onDragStart, onNodeClick }: AddNodesDr
<List
sx={{
width: '100%',
maxWidth: 370,
py: 0,
borderRadius: '10px',
borderRadius: tokens.borderRadius.lg,
'& .MuiListItemSecondaryAction-root': {
top: 22
},
@@ -284,104 +284,14 @@ function AddNodesDrawerComponent({ nodes, onDragStart, onNodeClick }: AddNodesDr
</AccordionSummary>
<AccordionDetails sx={{ p: 0 }}>
{filteredNodes[category].map((node, index) => (
<div
<NodeListItem
key={node.name}
onDragStart={(event) => handleDragStart(event, node)}
draggable
>
<ListItemButton
sx={{
p: 0,
borderRadius: '8px',
cursor: 'grab',
'&:active': { cursor: 'grabbing' }
}}
onClick={() => handleNodeClick(node)}
>
<ListItem alignItems='center'>
{node.color && !node.icon ? (
<ListItemAvatar>
<div
style={{
width: 50,
height: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
{renderIcon(node)}
</div>
</ListItemAvatar>
) : (
<ListItemAvatar>
<div
style={{
width: 50,
height: 50,
borderRadius: '50%',
backgroundColor: 'white'
}}
>
<img
style={{
width: '100%',
height: '100%',
padding: 10,
objectFit: 'contain'
}}
alt={node.name}
src={`${apiBaseUrl}/api/v1/node-icon/${node.name}`}
/>
</div>
</ListItemAvatar>
)}
<ListItemText
sx={{ ml: 1 }}
primary={
<div
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center'
}}
>
<span>{node.label}</span>
{typeof node.badge === 'string' &&
node.badge && (
<>
&nbsp;
<Chip
sx={{
width: 'max-content',
fontWeight: 700,
fontSize: '0.65rem',
background:
node.badge ===
'DEPRECATING'
? theme.palette
.warning.main
: theme.palette
.success.main,
color:
node.badge !==
'DEPRECATING'
? 'white'
: 'inherit'
}}
size='small'
label={node.badge}
/>
</>
)}
</div>
}
secondary={node.description}
/>
</ListItem>
</ListItemButton>
{index === filteredNodes[category].length - 1 ? null : <Divider />}
</div>
node={node}
apiBaseUrl={apiBaseUrl}
isLast={index === filteredNodes[category].length - 1}
onDragStart={handleDragStart}
onClick={handleNodeClick}
/>
))}
</AccordionDetails>
</Accordion>
@@ -0,0 +1,122 @@
import { memo } from 'react'
import { Box, Chip, Divider, ListItem, ListItemAvatar, ListItemButton, ListItemText } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { AGENTFLOW_ICONS } from '@/core'
import { tokens } from '@/core/theme/tokens'
import type { NodeData } from '@/core/types'
const NODE_ICON_SIZE = 30
const NODE_AVATAR_SIZE = 50
interface NodeListItemProps {
node: NodeData
apiBaseUrl: string
isLast: boolean
onDragStart: (event: React.DragEvent, node: NodeData) => void
onClick: (node: NodeData) => void
}
function NodeListItemComponent({ node, apiBaseUrl, isLast, onDragStart, onClick }: NodeListItemProps) {
const theme = useTheme()
const handleDragStart = (event: React.DragEvent) => {
event.dataTransfer.setData('application/reactflow', JSON.stringify(node))
event.dataTransfer.effectAllowed = 'move'
onDragStart(event, node)
}
const renderIcon = () => {
const foundIcon = AGENTFLOW_ICONS.find((icon) => icon.name === node.name)
if (foundIcon) {
const IconComponent = foundIcon.icon
return <IconComponent size={NODE_ICON_SIZE} color={node.color} />
}
return null
}
return (
<Box onDragStart={handleDragStart} draggable>
<ListItemButton
sx={{
p: 0,
borderRadius: tokens.borderRadius.md,
cursor: 'grab',
'&:active': { cursor: 'grabbing' }
}}
onClick={() => onClick(node)}
>
<ListItem alignItems='center'>
{node.color && !node.icon ? (
<ListItemAvatar>
<Box
sx={{
width: NODE_AVATAR_SIZE,
height: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
{renderIcon()}
</Box>
</ListItemAvatar>
) : (
<ListItemAvatar>
<Box
sx={{
width: NODE_AVATAR_SIZE,
height: NODE_AVATAR_SIZE,
borderRadius: '50%',
backgroundColor: 'white'
}}
>
<Box
component='img'
sx={{
width: '100%',
height: '100%',
p: '10px',
objectFit: 'contain'
}}
alt={node.name}
src={`${apiBaseUrl}/api/v1/node-icon/${node.name}`}
/>
</Box>
</ListItemAvatar>
)}
<ListItemText
sx={{ ml: 1 }}
primary={
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<span>{node.label}</span>
{typeof node.badge === 'string' && node.badge && (
<>
&nbsp;
<Chip
sx={{
width: 'max-content',
fontWeight: 700,
fontSize: '0.65rem',
background:
node.badge === 'DEPRECATING' ? theme.palette.warning.main : theme.palette.success.main,
color: node.badge !== 'DEPRECATING' ? 'white' : 'inherit'
}}
size='small'
label={node.badge}
/>
</>
)}
</Box>
}
secondary={node.description}
/>
</ListItem>
</ListItemButton>
{!isLast && <Divider />}
</Box>
)
}
export const NodeListItem = memo(NodeListItemComponent)
@@ -0,0 +1,37 @@
import { type RefObject, useEffect, useState } from 'react'
import { tokens } from '@/core/theme/tokens'
/**
* Calculates the maximum height for a drawer based on its rendered position in the viewport.
* Recalculates on window resize.
*
* @param open - Whether the drawer is currently open
* @param ref - Ref to the element whose position is measured
* @param bottomPadding - Padding from the viewport bottom (defaults to tokens.spacing.xxl)
*/
export function useDrawerMaxHeight(
open: boolean,
ref: RefObject<HTMLElement | null>,
bottomPadding = tokens.spacing.xxl
): number | undefined {
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined)
useEffect(() => {
if (open && ref.current) {
const update = () => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect()
setMaxHeight(window.innerHeight - rect.top - bottomPadding)
}
}
// Allow Popper to position first, then measure
requestAnimationFrame(update)
window.addEventListener('resize', update)
return () => window.removeEventListener('resize', update)
}
setMaxHeight(undefined)
}, [open, ref, bottomPadding])
return maxHeight
}