Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: make the Unleash AI chat resizable #8456

Merged
merged 2 commits into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 38 additions & 22 deletions frontend/src/component/ai/AIChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { AIChatInput } from './AIChatInput';
import { AIChatMessage } from './AIChatMessage';
import { AIChatHeader } from './AIChatHeader';
import { Resizable } from 'component/common/Resizable/Resizable';

const StyledAIIconContainer = styled('div')(({ theme }) => ({
position: 'fixed',
Expand All @@ -37,19 +38,26 @@ const StyledAIChatContainer = styled(StyledAIIconContainer)({
right: 10,
});

const StyledResizable = styled(Resizable)(({ theme }) => ({
boxShadow: theme.boxShadows.popup,
borderRadius: theme.shape.borderRadiusLarge,
}));

const StyledAIIconButton = styled(IconButton)(({ theme }) => ({
background: theme.palette.primary.light,
color: theme.palette.primary.contrastText,
boxShadow: theme.boxShadows.popup,
transition: 'background 0.3s',
'&:hover': {
background: theme.palette.primary.dark,
},
}));

const StyledChat = styled('div')(({ theme }) => ({
borderRadius: theme.shape.borderRadiusLarge,
display: 'flex',
flex: 1,
flexDirection: 'column',
overflow: 'hidden',
boxShadow: theme.boxShadows.popup,
background: theme.palette.background.paper,
}));

Expand All @@ -58,9 +66,9 @@ const StyledChatContent = styled('div')(({ theme }) => ({
flexDirection: 'column',
padding: theme.spacing(2),
paddingBottom: theme.spacing(1),
width: '30vw',
height: '50vh',
overflow: 'auto',
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
}));

const initialMessages: ChatMessage[] = [
Expand Down Expand Up @@ -134,24 +142,32 @@ export const AIChat = () => {

return (
<StyledAIChatContainer>
<StyledChat>
<AIChatHeader
onNew={() => setMessages(initialMessages)}
onClose={() => setOpen(false)}
/>
<StyledChatContent>
<AIChatMessage from='assistant'>
Hello, how can I assist you?
</AIChatMessage>
{messages.map(({ role, content }, index) => (
<AIChatMessage key={index} from={role}>
{content}
<StyledResizable
handlers={['top-left', 'top', 'left']}
minSize={{ width: '270px', height: '200px' }}
maxSize={{ width: '90vw', height: '90vh' }}
defaultSize={{ width: '320px', height: '450px' }}
onResize={scrollToEnd}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see cases where this would be annoying, ie you scroll up in history and then resize to read something without too many line breaks. But not sure the need would arise in here

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you try this locally I think you'll understand why I did this. If we don't do something like this, whenever you resize, the conversation content container will anchor to the top left.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll keep it for now but we can remove this behavior if we get feedback about it.

>
<StyledChat>
<AIChatHeader
onNew={() => setMessages(initialMessages)}
onClose={() => setOpen(false)}
/>
<StyledChatContent>
<AIChatMessage from='assistant'>
Hello, how can I assist you?
</AIChatMessage>
))}
<div ref={chatEndRef} />
</StyledChatContent>
<AIChatInput onSend={onSend} loading={loading} />
</StyledChat>
{messages.map(({ role, content }, index) => (
<AIChatMessage key={index} from={role}>
{content}
</AIChatMessage>
))}
<div ref={chatEndRef} />
</StyledChatContent>
<AIChatInput onSend={onSend} loading={loading} />
</StyledChat>
</StyledResizable>
</StyledAIChatContainer>
);
};
296 changes: 296 additions & 0 deletions frontend/src/component/common/Resizable/Resizable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
import { styled } from '@mui/material';
import { type HTMLAttributes, useRef, useState, type ReactNode } from 'react';

const StyledResizableWrapper = styled('div', {
shouldForwardProp: (prop) => prop !== 'animate',
})<{ animate: boolean }>(({ animate }) => ({
display: 'flex',
position: 'relative',
overflow: 'hidden',
transition: animate ? 'width 0.3s, height 0.3s' : 'none',
}));

const StyledResizeHandle = styled('div')({
position: 'absolute',
background: 'transparent',
zIndex: 1,
'&.top-left': {
top: 0,
left: 0,
cursor: 'nwse-resize',
width: '10px',
height: '10px',
zIndex: 2,
},
'&.top-right': {
top: 0,
right: 0,
cursor: 'nesw-resize',
width: '10px',
height: '10px',
zIndex: 2,
},
'&.bottom-left': {
bottom: 0,
left: 0,
cursor: 'nesw-resize',
width: '10px',
height: '10px',
zIndex: 2,
},
'&.bottom-right': {
bottom: 0,
right: 0,
cursor: 'nwse-resize',
width: '10px',
height: '10px',
zIndex: 2,
},
'&.top': {
top: 0,
left: '50%',
cursor: 'ns-resize',
width: '100%',
height: '5px',
transform: 'translateX(-50%)',
},
'&.right': {
top: '50%',
right: 0,
cursor: 'ew-resize',
width: '5px',
height: '100%',
transform: 'translateY(-50%)',
},
'&.bottom': {
bottom: 0,
left: '50%',
cursor: 'ns-resize',
width: '100%',
height: '5px',
transform: 'translateX(-50%)',
},
'&.left': {
top: '50%',
left: 0,
cursor: 'ew-resize',
width: '5px',
height: '100%',
transform: 'translateY(-50%)',
},
});

type Handler =
| 'top'
| 'right'
| 'bottom'
| 'left'
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right';

type Size = { width: string; height: string };

interface IResizableProps extends HTMLAttributes<HTMLDivElement> {
handlers: Handler[];
minSize: Size;
maxSize: Size;
defaultSize?: Size;
onResize?: () => void;
onResizeEnd?: () => void;
children: ReactNode;
}

export const Resizable = ({
handlers,
minSize,
maxSize,
defaultSize = minSize,
onResize,
onResizeEnd,
children,
...props
}: IResizableProps) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const [currentSize, setCurrentSize] = useState(defaultSize);
const [animate, setAnimate] = useState(false);

const handleResize = (
e: React.MouseEvent<HTMLDivElement>,
direction:
| 'top'
| 'right'
| 'bottom'
| 'left'
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right',
) => {
e.preventDefault();

const chatContainer = containerRef.current;
if (!chatContainer) return;

const startX = e.clientX;
const startY = e.clientY;
const startWidth = chatContainer.offsetWidth;
const startHeight = chatContainer.offsetHeight;

setAnimate(false);

const onMouseMove = (moveEvent: MouseEvent) => {
let newWidth = startWidth;
let newHeight = startHeight;

if (direction.includes('top')) {
newHeight = Math.max(
Number.parseInt(minSize.height),
startHeight - (moveEvent.clientY - startY),
);
}

if (direction.includes('bottom')) {
newHeight = Math.max(
Number.parseInt(minSize.height),
startHeight + (moveEvent.clientY - startY),
);
}

if (direction.includes('left')) {
newWidth = Math.max(
Number.parseInt(minSize.width),
startWidth - (moveEvent.clientX - startX),
);
}

if (direction.includes('right')) {
newWidth = Math.max(
Number.parseInt(minSize.width),
startWidth + (moveEvent.clientX - startX),
);
}

setCurrentSize({
width: `${newWidth}px`,
height: `${newHeight}px`,
});

onResize?.();
};

const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);

onResizeEnd?.();
};

document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};

const handleDoubleClick = (direction: Handler) => {
const chatContainer = containerRef.current;
if (!chatContainer) return;

const currentWidth = chatContainer.style.width;
const currentHeight = chatContainer.style.height;

setAnimate(true);

if (direction.includes('top') || direction.includes('bottom')) {
if (currentHeight === maxSize.height) {
chatContainer.style.height = defaultSize.height;
} else {
chatContainer.style.height = maxSize.height;
}
}

if (direction.includes('left') || direction.includes('right')) {
if (currentWidth === maxSize.width) {
chatContainer.style.width = defaultSize.width;
} else {
chatContainer.style.width = maxSize.width;
}
}

onResizeEnd?.();
};

return (
<StyledResizableWrapper
ref={containerRef}
animate={animate}
{...props}
style={{
width: currentSize.width,
height: currentSize.height,
minWidth: minSize.width,
minHeight: minSize.height,
maxWidth: maxSize.width,
maxHeight: maxSize.height,
}}
>
{children}

{handlers.includes('top-left') && (
<StyledResizeHandle
className='top-left'
onMouseDown={(e) => handleResize(e, 'top-left')}
onDoubleClick={() => handleDoubleClick('top-left')}
/>
)}
{handlers.includes('top-right') && (
<StyledResizeHandle
className='top-right'
onMouseDown={(e) => handleResize(e, 'top-right')}
onDoubleClick={() => handleDoubleClick('top-right')}
/>
)}
{handlers.includes('bottom-left') && (
<StyledResizeHandle
className='bottom-left'
onMouseDown={(e) => handleResize(e, 'bottom-left')}
onDoubleClick={() => handleDoubleClick('bottom-left')}
/>
)}
{handlers.includes('bottom-right') && (
<StyledResizeHandle
className='bottom-right'
onMouseDown={(e) => handleResize(e, 'bottom-right')}
onDoubleClick={() => handleDoubleClick('bottom-right')}
/>
)}
{handlers.includes('top') && (
<StyledResizeHandle
className='top'
onMouseDown={(e) => handleResize(e, 'top')}
onDoubleClick={() => handleDoubleClick('top')}
/>
)}
{handlers.includes('right') && (
<StyledResizeHandle
className='right'
onMouseDown={(e) => handleResize(e, 'right')}
onDoubleClick={() => handleDoubleClick('right')}
/>
)}
{handlers.includes('bottom') && (
<StyledResizeHandle
className='bottom'
onMouseDown={(e) => handleResize(e, 'bottom')}
onDoubleClick={() => handleDoubleClick('bottom')}
/>
)}
{handlers.includes('left') && (
<StyledResizeHandle
className='left'
onMouseDown={(e) => handleResize(e, 'left')}
onDoubleClick={() => handleDoubleClick('left')}
/>
)}
</StyledResizableWrapper>
);
};
Loading