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/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;
}