Canvas: Render AI agents in tabs
Implements AI Agent chat bubble, but is disabled for now.
Change-Id: If915691a22f376f347b76a5d24333dbe76492ca9
diff --git a/apps/canvas/front/src/components/ChatBubble.tsx b/apps/canvas/front/src/components/ChatBubble.tsx
new file mode 100644
index 0000000..74e3605
--- /dev/null
+++ b/apps/canvas/front/src/components/ChatBubble.tsx
@@ -0,0 +1,191 @@
+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>
+ );
+}