blob: 74e3605897587ace0399a4ee846ee4aa4fbf5463 [file] [log] [blame]
import React, { useState, useRef, useEffect, useCallback } from "react";
interface ChatBubbleProps {
agentName: string;
agentUrl: string;
initialPosition?: { bottom?: number; right?: number; top?: number; left?: number };
}
const MIN_WIDTH = 200;
const MIN_HEIGHT = 150;
const DEFAULT_WIDTH = 350;
const DEFAULT_HEIGHT = 500;
export function ChatBubble({ agentName, agentUrl, initialPosition = { bottom: 20, right: 20 } }: ChatBubbleProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [position, setPosition] = useState(initialPosition);
const [size, setSize] = useState({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT });
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0 });
const bubbleRef = useRef<HTMLDivElement>(null);
const toggleExpansion = useCallback(
(e: React.MouseEvent) => {
if (isResizing) {
return;
}
if (
isExpanded &&
(e.target === e.currentTarget || (e.target as HTMLElement).closest("button") === e.currentTarget)
) {
e.stopPropagation();
}
setIsExpanded((prev) => !prev);
if (!isExpanded) {
setSize({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT });
}
},
[isExpanded, isResizing],
);
const handleDragMouseDown = useCallback((e: React.MouseEvent) => {
if (!bubbleRef.current || (e.target as HTMLElement).closest("button")) {
return;
}
setIsDragging(true);
const rect = bubbleRef.current.getBoundingClientRect();
setDragOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
e.preventDefault();
}, []);
const handleDragMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging) return;
setPosition({
top: e.clientY - dragOffset.y,
left: e.clientX - dragOffset.x,
bottom: undefined,
right: undefined,
});
},
[isDragging, dragOffset],
);
const handleDragMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
const handleResizeMouseDown = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
setIsResizing(true);
setResizeStart({
x: e.clientX,
y: e.clientY,
width: size.width,
height: size.height,
});
e.preventDefault();
},
[size],
);
const handleResizeMouseMove = useCallback(
(e: MouseEvent) => {
if (!isResizing) return;
const newWidth = Math.max(MIN_WIDTH, resizeStart.width + (e.clientX - resizeStart.x));
const newHeight = Math.max(MIN_HEIGHT, resizeStart.height + (e.clientY - resizeStart.y));
setSize({ width: newWidth, height: newHeight });
},
[isResizing, resizeStart],
);
const handleResizeMouseUp = useCallback(() => {
setIsResizing(false);
}, []);
useEffect(() => {
const activeDragging = isDragging;
const activeResizing = isResizing;
const globalMouseMove = (e: MouseEvent) => {
if (activeDragging) handleDragMouseMove(e);
if (activeResizing) handleResizeMouseMove(e);
};
const globalMouseUp = () => {
if (activeDragging) handleDragMouseUp();
if (activeResizing) handleResizeMouseUp();
};
if (activeDragging || activeResizing) {
window.addEventListener("mousemove", globalMouseMove);
window.addEventListener("mouseup", globalMouseUp);
} else {
window.removeEventListener("mousemove", globalMouseMove);
window.removeEventListener("mouseup", globalMouseUp);
}
return () => {
window.removeEventListener("mousemove", globalMouseMove);
window.removeEventListener("mouseup", globalMouseUp);
};
}, [isDragging, isResizing, handleDragMouseMove, handleDragMouseUp, handleResizeMouseMove, handleResizeMouseUp]);
const baseBubbleClasses = `fixed border shadow-lg z-[1000] flex flex-col bg-card text-card-foreground p-3`;
const collapsedClasses = `min-w-[150px] h-auto rounded-lg items-start`;
const expandedDynamicClasses = `rounded-lg items-center`;
const bubbleDynamicClasses = `${baseBubbleClasses} ${isExpanded ? expandedDynamicClasses : collapsedClasses}`;
const headerClasses = "w-full flex justify-between items-center cursor-grab select-none";
const iframeContainerClasses = "w-full flex-grow mt-2.5 relative";
const iframeClasses = "w-full h-full border-none rounded-b-md";
const buttonClasses = "bg-transparent border-none text-card-foreground p-1 leading-none cursor-pointer";
const resizeHandleClasses =
"absolute bottom-0 right-0 w-4 h-4 cursor-se-resize bg-gray-400 opacity-50 hover:opacity-100";
const bubbleStyle: React.CSSProperties = {
top: position.top !== undefined ? `${position.top}px` : undefined,
left: position.left !== undefined ? `${position.left}px` : undefined,
bottom: position.bottom !== undefined && position.top === undefined ? `${position.bottom}px` : undefined,
right: position.right !== undefined && position.left === undefined ? `${position.right}px` : undefined,
width: isExpanded ? `${size.width}px` : undefined,
height: isExpanded ? `${size.height}px` : undefined,
};
return (
<div
ref={bubbleRef}
className={bubbleDynamicClasses}
style={bubbleStyle}
onClick={!isExpanded && !isDragging && !isResizing ? toggleExpansion : undefined}
>
<div
className={headerClasses}
onMouseDown={handleDragMouseDown}
onDoubleClick={(e) => {
e.stopPropagation();
toggleExpansion(e);
}}
>
<span className="font-semibold truncate pr-2">{agentName}</span>
<button
onClick={(e) => {
e.stopPropagation();
toggleExpansion(e);
}}
className={buttonClasses}
aria-label={isExpanded ? "Collapse chat" : "Expand chat"}
>
{isExpanded ? "▼" : "▲"}
</button>
</div>
{isExpanded && (
<div className={iframeContainerClasses}>
<iframe
src={agentUrl}
title={agentName}
className={iframeClasses}
style={{ pointerEvents: isDragging || isResizing ? "none" : "auto" }}
/>
<div className={resizeHandleClasses} onMouseDown={handleResizeMouseDown} />
</div>
)}
</div>
);
}