blob: 74e3605897587ace0399a4ee846ee4aa4fbf5463 [file] [log] [blame]
giocc5ce582025-06-25 07:45:21 +04001import React, { useState, useRef, useEffect, useCallback } from "react";
2
3interface ChatBubbleProps {
4 agentName: string;
5 agentUrl: string;
6 initialPosition?: { bottom?: number; right?: number; top?: number; left?: number };
7}
8
9const MIN_WIDTH = 200;
10const MIN_HEIGHT = 150;
11const DEFAULT_WIDTH = 350;
12const DEFAULT_HEIGHT = 500;
13
14export function ChatBubble({ agentName, agentUrl, initialPosition = { bottom: 20, right: 20 } }: ChatBubbleProps) {
15 const [isExpanded, setIsExpanded] = useState(false);
16 const [position, setPosition] = useState(initialPosition);
17 const [size, setSize] = useState({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT });
18 const [isDragging, setIsDragging] = useState(false);
19 const [isResizing, setIsResizing] = useState(false);
20 const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
21 const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0 });
22
23 const bubbleRef = useRef<HTMLDivElement>(null);
24
25 const toggleExpansion = useCallback(
26 (e: React.MouseEvent) => {
27 if (isResizing) {
28 return;
29 }
30 if (
31 isExpanded &&
32 (e.target === e.currentTarget || (e.target as HTMLElement).closest("button") === e.currentTarget)
33 ) {
34 e.stopPropagation();
35 }
36 setIsExpanded((prev) => !prev);
37 if (!isExpanded) {
38 setSize({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT });
39 }
40 },
41 [isExpanded, isResizing],
42 );
43
44 const handleDragMouseDown = useCallback((e: React.MouseEvent) => {
45 if (!bubbleRef.current || (e.target as HTMLElement).closest("button")) {
46 return;
47 }
48 setIsDragging(true);
49 const rect = bubbleRef.current.getBoundingClientRect();
50 setDragOffset({
51 x: e.clientX - rect.left,
52 y: e.clientY - rect.top,
53 });
54 e.preventDefault();
55 }, []);
56
57 const handleDragMouseMove = useCallback(
58 (e: MouseEvent) => {
59 if (!isDragging) return;
60 setPosition({
61 top: e.clientY - dragOffset.y,
62 left: e.clientX - dragOffset.x,
63 bottom: undefined,
64 right: undefined,
65 });
66 },
67 [isDragging, dragOffset],
68 );
69
70 const handleDragMouseUp = useCallback(() => {
71 setIsDragging(false);
72 }, []);
73
74 const handleResizeMouseDown = useCallback(
75 (e: React.MouseEvent) => {
76 e.stopPropagation();
77 setIsResizing(true);
78 setResizeStart({
79 x: e.clientX,
80 y: e.clientY,
81 width: size.width,
82 height: size.height,
83 });
84 e.preventDefault();
85 },
86 [size],
87 );
88
89 const handleResizeMouseMove = useCallback(
90 (e: MouseEvent) => {
91 if (!isResizing) return;
92 const newWidth = Math.max(MIN_WIDTH, resizeStart.width + (e.clientX - resizeStart.x));
93 const newHeight = Math.max(MIN_HEIGHT, resizeStart.height + (e.clientY - resizeStart.y));
94 setSize({ width: newWidth, height: newHeight });
95 },
96 [isResizing, resizeStart],
97 );
98
99 const handleResizeMouseUp = useCallback(() => {
100 setIsResizing(false);
101 }, []);
102
103 useEffect(() => {
104 const activeDragging = isDragging;
105 const activeResizing = isResizing;
106
107 const globalMouseMove = (e: MouseEvent) => {
108 if (activeDragging) handleDragMouseMove(e);
109 if (activeResizing) handleResizeMouseMove(e);
110 };
111 const globalMouseUp = () => {
112 if (activeDragging) handleDragMouseUp();
113 if (activeResizing) handleResizeMouseUp();
114 };
115
116 if (activeDragging || activeResizing) {
117 window.addEventListener("mousemove", globalMouseMove);
118 window.addEventListener("mouseup", globalMouseUp);
119 } else {
120 window.removeEventListener("mousemove", globalMouseMove);
121 window.removeEventListener("mouseup", globalMouseUp);
122 }
123 return () => {
124 window.removeEventListener("mousemove", globalMouseMove);
125 window.removeEventListener("mouseup", globalMouseUp);
126 };
127 }, [isDragging, isResizing, handleDragMouseMove, handleDragMouseUp, handleResizeMouseMove, handleResizeMouseUp]);
128
129 const baseBubbleClasses = `fixed border shadow-lg z-[1000] flex flex-col bg-card text-card-foreground p-3`;
130 const collapsedClasses = `min-w-[150px] h-auto rounded-lg items-start`;
131 const expandedDynamicClasses = `rounded-lg items-center`;
132
133 const bubbleDynamicClasses = `${baseBubbleClasses} ${isExpanded ? expandedDynamicClasses : collapsedClasses}`;
134
135 const headerClasses = "w-full flex justify-between items-center cursor-grab select-none";
136 const iframeContainerClasses = "w-full flex-grow mt-2.5 relative";
137 const iframeClasses = "w-full h-full border-none rounded-b-md";
138 const buttonClasses = "bg-transparent border-none text-card-foreground p-1 leading-none cursor-pointer";
139 const resizeHandleClasses =
140 "absolute bottom-0 right-0 w-4 h-4 cursor-se-resize bg-gray-400 opacity-50 hover:opacity-100";
141
142 const bubbleStyle: React.CSSProperties = {
143 top: position.top !== undefined ? `${position.top}px` : undefined,
144 left: position.left !== undefined ? `${position.left}px` : undefined,
145 bottom: position.bottom !== undefined && position.top === undefined ? `${position.bottom}px` : undefined,
146 right: position.right !== undefined && position.left === undefined ? `${position.right}px` : undefined,
147 width: isExpanded ? `${size.width}px` : undefined,
148 height: isExpanded ? `${size.height}px` : undefined,
149 };
150
151 return (
152 <div
153 ref={bubbleRef}
154 className={bubbleDynamicClasses}
155 style={bubbleStyle}
156 onClick={!isExpanded && !isDragging && !isResizing ? toggleExpansion : undefined}
157 >
158 <div
159 className={headerClasses}
160 onMouseDown={handleDragMouseDown}
161 onDoubleClick={(e) => {
162 e.stopPropagation();
163 toggleExpansion(e);
164 }}
165 >
166 <span className="font-semibold truncate pr-2">{agentName}</span>
167 <button
168 onClick={(e) => {
169 e.stopPropagation();
170 toggleExpansion(e);
171 }}
172 className={buttonClasses}
173 aria-label={isExpanded ? "Collapse chat" : "Expand chat"}
174 >
175 {isExpanded ? "▼" : "▲"}
176 </button>
177 </div>
178 {isExpanded && (
179 <div className={iframeContainerClasses}>
180 <iframe
181 src={agentUrl}
182 title={agentName}
183 className={iframeClasses}
184 style={{ pointerEvents: isDragging || isResizing ? "none" : "auto" }}
185 />
186 <div className={resizeHandleClasses} onMouseDown={handleResizeMouseDown} />
187 </div>
188 )}
189 </div>
190 );
191}