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>
+	);
+}
diff --git a/apps/canvas/front/src/components/ChatManager.tsx b/apps/canvas/front/src/components/ChatManager.tsx
new file mode 100644
index 0000000..d5db0f3
--- /dev/null
+++ b/apps/canvas/front/src/components/ChatManager.tsx
@@ -0,0 +1,37 @@
+import React from "react";
+import { ChatBubble } from "./ChatBubble";
+import { useAgents } from "@/lib/state";
+
+// Approximate height of a collapsed bubble + gap for cascading
+const BUBBLE_CASCADE_OFFSET = 70; // e.g., 50px height + 10px padding + 10px gap
+const INITIAL_BOTTOM_OFFSET = 20;
+const INITIAL_RIGHT_OFFSET = 20;
+
+export function ChatManager(): React.ReactNode {
+	// TODO(gio): reconsider
+	return null;
+	const agents = useAgents();
+	if (agents.length === 0) {
+		return null;
+	}
+	return (
+		<>
+			{agents.map((agent, index) => {
+				const initialPosition = {
+					bottom: INITIAL_BOTTOM_OFFSET + index * BUBBLE_CASCADE_OFFSET,
+					right: INITIAL_RIGHT_OFFSET,
+					top: undefined, // Ensure top/left are not set if bottom/right are
+					left: undefined,
+				};
+				return (
+					<ChatBubble
+						key={agent.name}
+						agentName={agent.name}
+						agentUrl={agent.address}
+						initialPosition={initialPosition}
+					/>
+				);
+			})}
+		</>
+	);
+}
diff --git a/apps/canvas/front/src/components/icon.tsx b/apps/canvas/front/src/components/icon.tsx
index 6bded3a..9a7c564 100644
--- a/apps/canvas/front/src/components/icon.tsx
+++ b/apps/canvas/front/src/components/icon.tsx
@@ -4,11 +4,10 @@
 import { GoFileDirectoryFill } from "react-icons/go";
 import { TbWorldWww } from "react-icons/tb";
 import { PiNetwork } from "react-icons/pi";
-import { AiOutlineGlobal } from "react-icons/ai"; // Corrected import source
-import { Bot } from "lucide-react"; // Bot import
+import { AiOutlineGlobal } from "react-icons/ai";
+import { Bot } from "lucide-react";
 import { Terminal } from "lucide-react";
-import { z } from "zod";
-import { AppNode, accessSchema } from "config";
+import { AppNode, Access } from "config";
 
 type Props = {
 	node: AppNode | undefined;
@@ -45,10 +44,14 @@
 	}
 }
 
-export function AccessType({ type, className }: { type: z.infer<typeof accessSchema>["type"]; className?: string }) {
-	switch (type) {
+export function AccessType({ access, className }: { access: Access; className?: string }) {
+	switch (access.type) {
 		case "https":
-			return <TbWorldWww className={className} />;
+			if (access.agentName) {
+				return <Bot className={className} />;
+			} else {
+				return <TbWorldWww className={className} />;
+			}
 		case "ssh":
 			return <Terminal className={className} />;
 		case "tcp":