Canvas: Render AI agents in tabs

Implements AI Agent chat bubble, but is disabled for now.

Change-Id: If915691a22f376f347b76a5d24333dbe76492ca9
diff --git a/apps/canvas/back/package-lock.json b/apps/canvas/back/package-lock.json
index 03f0e80..25d7214 100644
--- a/apps/canvas/back/package-lock.json
+++ b/apps/canvas/back/package-lock.json
@@ -50,8 +50,11 @@
         "zod": "^3.24.4"
       },
       "devDependencies": {
+        "@types/jest": "^30.0.0",
         "eslint": "^9.13.0",
+        "jest": "^29.7.0",
         "prettier": "3.5.3",
+        "ts-jest": "^29.4.0",
         "typescript": "^5.8.3",
         "typescript-eslint": "^8.11.0"
       }
diff --git a/apps/canvas/back/src/app_manager.ts b/apps/canvas/back/src/app_manager.ts
index faf64e2..34bdd5e 100644
--- a/apps/canvas/back/src/app_manager.ts
+++ b/apps/canvas/back/src/app_manager.ts
@@ -1,11 +1,12 @@
 import axios from "axios";
 import { z } from "zod";
 
-const accessSchema = z.discriminatedUnion("type", [
+export const accessSchema = z.discriminatedUnion("type", [
 	z.object({
 		type: z.literal("https"),
 		name: z.string(),
 		address: z.string(),
+		agentName: z.string().optional(),
 	}),
 	z.object({
 		type: z.literal("ssh"),
diff --git a/apps/canvas/config/src/graph.ts b/apps/canvas/config/src/graph.ts
index c259d8f..94708b4 100644
--- a/apps/canvas/config/src/graph.ts
+++ b/apps/canvas/config/src/graph.ts
@@ -241,6 +241,7 @@
 		type: z.literal("https"),
 		name: z.string(),
 		address: z.string(),
+		agentName: z.string().optional(),
 	}),
 	z.object({
 		type: z.literal("ssh"),
@@ -321,3 +322,4 @@
 
 export type ServiceInfo = z.infer<typeof serviceInfoSchema>;
 export type Env = z.infer<typeof envSchema>;
+export type Access = z.infer<typeof accessSchema>;
diff --git a/apps/canvas/config/src/index.ts b/apps/canvas/config/src/index.ts
index a8f21de..05b3291 100644
--- a/apps/canvas/config/src/index.ts
+++ b/apps/canvas/config/src/index.ts
@@ -47,6 +47,7 @@
 	GithubData,
 	envSchema,
 	accessSchema,
+	Access,
 } from "./graph.js";
 
 export { generateDodoConfig, configToGraph } from "./config.js";
diff --git a/apps/canvas/front/package-lock.json b/apps/canvas/front/package-lock.json
index 0855cc5..1913878 100644
--- a/apps/canvas/front/package-lock.json
+++ b/apps/canvas/front/package-lock.json
@@ -67,11 +67,15 @@
 			"license": "ISC",
 			"dependencies": {
 				"@xyflow/react": "^12.3.3",
+				"uuid": "^11.0.2",
 				"zod": "^3.24.4"
 			},
 			"devDependencies": {
+				"@types/jest": "^30.0.0",
 				"eslint": "^9.13.0",
+				"jest": "^29.7.0",
 				"prettier": "3.5.3",
+				"ts-jest": "^29.4.0",
 				"typescript": "^5.8.3",
 				"typescript-eslint": "^8.11.0"
 			}
diff --git a/apps/canvas/front/src/App.tsx b/apps/canvas/front/src/App.tsx
index 794b433..a9fd3a6 100644
--- a/apps/canvas/front/src/App.tsx
+++ b/apps/canvas/front/src/App.tsx
@@ -8,12 +8,16 @@
 import { ProjectSelect } from "./ProjectSelect";
 import { Logs } from "./Monitoring";
 import { Overview } from "./Overview";
+import { ChatManager } from "./components/ChatManager";
+import { useAgents } from "./lib/state";
+import { Bot } from "lucide-react";
 
 export default function App() {
 	return (
 		<ReactFlowProvider>
 			<div className="h-screen flex flex-col p-1">
 				<AppImpl />
+				<ChatManager />
 				<Toaster />
 			</div>
 		</ReactFlowProvider>
@@ -21,6 +25,7 @@
 }
 
 function AppImpl() {
+	const agents = useAgents();
 	return (
 		<Tabs defaultValue="overview" className="flex-1 flex flex-col min-h-0">
 			<div className="flex justify-between border-b">
@@ -30,6 +35,18 @@
 					<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
 					<TabsTrigger value="config">Config</TabsTrigger>
 					<TabsTrigger value="integrations">Integrations</TabsTrigger>
+					{agents.map((a) => {
+						return (
+							<TabsTrigger
+								key={a.agentName}
+								value={`agent-${a.agentName}`}
+								className="flex flex-row items-center gap-1"
+							>
+								<Bot className="w-4 h-4" />
+								{a.agentName}
+							</TabsTrigger>
+						);
+					})}
 				</TabsList>
 				<ProjectSelect className="w-fit min-w-[150px]" />
 			</div>
@@ -48,6 +65,11 @@
 			<TabsContent value="monitoring" className="!mt-0 flex-1 min-h-0">
 				<Logs />
 			</TabsContent>
+			{agents.map((a) => (
+				<TabsContent value={`agent-${a.agentName}`} className="!mt-0 flex-1 min-h-0">
+					<iframe key={a.name} src={a.address} title={a.agentName} className="w-full h-full" />
+				</TabsContent>
+			))}
 		</Tabs>
 	);
 }
diff --git a/apps/canvas/front/src/Gateways.tsx b/apps/canvas/front/src/Gateways.tsx
index 626c433..dd4c482 100644
--- a/apps/canvas/front/src/Gateways.tsx
+++ b/apps/canvas/front/src/Gateways.tsx
@@ -1,10 +1,10 @@
-import { z } from "zod";
-import { accessSchema, useEnv } from "./lib/state";
+import { useEnv } from "./lib/state";
 import { Copy, Check } from "lucide-react";
 import { Button } from "./components/ui/button";
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./components/ui/tooltip";
 import { useCallback, useState } from "react";
 import { AccessType } from "./components/icon";
+import { Access } from "config";
 
 export function Gateways() {
 	const env = useEnv();
@@ -30,8 +30,8 @@
 	);
 }
 
-function Gateway({ g }: { g: z.infer<typeof accessSchema> }) {
-	const [hidden, content] = (() => {
+function Gateway({ g }: { g: Access }) {
+	const [hidden, content] = ((): [string, string] => {
 		switch (g.type) {
 			case "https":
 				return [g.address, g.address];
@@ -49,6 +49,8 @@
 					`mongodb://${g.username}:*****@${g.host}:${g.port}/${g.database}`,
 					`mongodb://${g.username}:${g.password}@${g.host}:${g.port}/${g.database}`,
 				];
+			default:
+				throw new Error(`Unknown gateway type: ${g.type}`);
 		}
 	})();
 	const [clicked, setClicked] = useState(false);
@@ -71,7 +73,7 @@
 						onClick={g.type === "https" ? () => window.open(content, "_blank") : copy}
 						className="!gap-1 !p-0 !h-fit"
 					>
-						<AccessType type={g.type} className="w-4 h-4" />
+						<AccessType access={g} className="w-4 h-4" />
 						<div className="hover:bg-gray-200 p-x-1">{hidden}</div>
 					</Button>
 				</TooltipTrigger>
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":
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index 3733e56..b5eac42 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -14,7 +14,7 @@
 import type { DeepPartial } from "react-hook-form";
 import { v4 as uuidv4 } from "uuid";
 import { create } from "zustand";
-import { AppNode, Env, NodeType, VolumeNode, GatewayTCPData, envSchema } from "config";
+import { AppNode, Env, NodeType, VolumeNode, GatewayTCPData, envSchema, Access } from "config";
 
 export function nodeLabel(n: AppNode): string {
 	try {
@@ -269,6 +269,12 @@
 	return useStateStore(envSelector);
 }
 
+export function useAgents(): Extract<Access, { type: "https" }>[] {
+	return useStateStore(envSelector).access.filter(
+		(acc): acc is Extract<Access, { type: "https" }> => acc.type === "https" && acc.agentName != null,
+	);
+}
+
 export function useGithubService(): boolean {
 	return useStateStore(envSelector).integrations.github;
 }