Canvas: Update layout

Combine separate Overview and Canvas tabs into one Build tab
Add Overview <-> Canvas switcher to Actions

Change-Id: I40f7742be587b475ae6e88af2bcf9cae34f93168
diff --git a/apps/canvas/front/src/Agent.tsx b/apps/canvas/front/src/Agent.tsx
new file mode 100644
index 0000000..9ed47d5
--- /dev/null
+++ b/apps/canvas/front/src/Agent.tsx
@@ -0,0 +1,6 @@
+import React from "react";
+import { AgentAccess } from "config";
+
+export function Agent({ agent }: { agent: AgentAccess }): React.ReactNode {
+	return <iframe key={agent.name} src={`${agent.address}?m`} title={agent.agentName} className="w-full h-full" />;
+}
diff --git a/apps/canvas/front/src/App.tsx b/apps/canvas/front/src/App.tsx
index a9fd3a6..711b076 100644
--- a/apps/canvas/front/src/App.tsx
+++ b/apps/canvas/front/src/App.tsx
@@ -1,13 +1,12 @@
 import { ReactFlowProvider } from "@xyflow/react";
 import "./App.css";
-import { CanvasBuilder } from "./Canvas";
+import { Build } from "./Build";
 import { Tabs, TabsTrigger, TabsContent, TabsList } from "./components/ui/tabs";
 import { Config } from "./Config";
 import { Integrations } from "./Integrations";
 import { Toaster } from "./components/ui/toaster";
 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";
@@ -27,11 +26,10 @@
 function AppImpl() {
 	const agents = useAgents();
 	return (
-		<Tabs defaultValue="overview" className="flex-1 flex flex-col min-h-0">
+		<Tabs defaultValue="build" className="flex-1 flex flex-col min-h-0">
 			<div className="flex justify-between border-b">
 				<TabsList className="!rounded-none">
-					<TabsTrigger value="overview">Overview</TabsTrigger>
-					<TabsTrigger value="canvas">Canvas</TabsTrigger>
+					<TabsTrigger value="build">Build</TabsTrigger>
 					<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
 					<TabsTrigger value="config">Config</TabsTrigger>
 					<TabsTrigger value="integrations">Integrations</TabsTrigger>
@@ -50,11 +48,8 @@
 				</TabsList>
 				<ProjectSelect className="w-fit min-w-[150px]" />
 			</div>
-			<TabsContent value="overview" className="!mt-0 flex-1 min-h-0">
-				<Overview />
-			</TabsContent>
-			<TabsContent value="canvas" className="!mt-0 flex-1 min-h-0">
-				<CanvasBuilder />
+			<TabsContent value="build" className="!mt-0 flex-1 min-h-0">
+				<Build />
 			</TabsContent>
 			<TabsContent value="config" className="!mt-0 flex-1 min-h-0">
 				<Config />
diff --git a/apps/canvas/front/src/Build.tsx b/apps/canvas/front/src/Build.tsx
new file mode 100644
index 0000000..a3960ba
--- /dev/null
+++ b/apps/canvas/front/src/Build.tsx
@@ -0,0 +1,66 @@
+import { Canvas } from "./Canvas";
+import { Details } from "./Details";
+import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./components/ui/resizable";
+import { Tools } from "./Tools";
+import { Agent } from "./Agent";
+import { useBuildMode, useLeadAgent } from "./lib/state";
+import { Overview } from "./Overview";
+import { useMemo } from "react";
+
+export function Build() {
+	const leadAgent = useLeadAgent();
+	const buildMode = useBuildMode();
+	const mainWidth = useMemo(() => {
+		let ret = 100;
+		if (leadAgent) {
+			ret -= 25;
+		}
+		if (buildMode === "canvas") {
+			ret -= 20;
+		}
+		return ret;
+	}, [leadAgent, buildMode]);
+	return (
+		<ResizablePanelGroup direction="horizontal" className="w-full h-full">
+			{leadAgent && (
+				<>
+					<ResizablePanel defaultSize={25}>
+						<Agent agent={leadAgent} />
+					</ResizablePanel>
+					<ResizableHandle withHandle />
+				</>
+			)}
+			<ResizablePanel defaultSize={mainWidth}>
+				<ResizablePanelGroup direction="vertical">
+					<ResizablePanel defaultSize={80}>
+						<OverviewOrCanvas />
+					</ResizablePanel>
+					<ResizableHandle withHandle />
+					<ResizablePanel defaultSize={20}>
+						<Tools />
+					</ResizablePanel>
+				</ResizablePanelGroup>
+			</ResizablePanel>
+			{buildMode === "canvas" && (
+				<>
+					<ResizableHandle withHandle />
+					<ResizablePanel defaultSize={20} className="!overflow-y-auto !overflow-x-hidden">
+						<Details />
+					</ResizablePanel>
+				</>
+			)}
+		</ResizablePanelGroup>
+	);
+}
+
+function OverviewOrCanvas() {
+	const buildMode = useBuildMode();
+	switch (buildMode) {
+		case "overview":
+			return <Overview />;
+		case "canvas":
+			return <Canvas />;
+		default:
+			throw new Error(`Unknown build mode: ${buildMode}`);
+	}
+}
diff --git a/apps/canvas/front/src/Canvas.tsx b/apps/canvas/front/src/Canvas.tsx
index eaaaf70..ba17153 100644
--- a/apps/canvas/front/src/Canvas.tsx
+++ b/apps/canvas/front/src/Canvas.tsx
@@ -1,41 +1,132 @@
-import { Resources } from "./components/resources";
-import { Canvas } from "./components/canvas";
-import { Details } from "./Details";
-import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./components/ui/resizable";
-import { Tools } from "./Tools";
-import { useStateStore } from "./lib/state";
+import "@xyflow/react/dist/style.css";
+import {
+	ReactFlow,
+	Background,
+	Controls,
+	Connection,
+	BackgroundVariant,
+	Edge,
+	useReactFlow,
+	Panel,
+	useStoreApi,
+} from "@xyflow/react";
+import { useStateStore, AppState, useZoom } from "@/lib/state";
+import { useShallow } from "zustand/react/shallow";
+import { useCallback, useEffect, useMemo } from "react";
+import { NodeGatewayHttps } from "@/components/node-gateway-https";
+import { NodeApp } from "@/components/node-app";
+import { NodeVolume } from "./components/node-volume";
+import { NodePostgreSQL } from "./components/node-postgresql";
+import { NodeMongoDB } from "./components/node-mongodb";
+import { NodeGithub } from "./components/node-github";
+import { Actions } from "./components/actions";
+import { NodeGatewayTCP } from "./components/node-gateway-tcp";
+import { NodeNetwork } from "./components/node-network";
+import { AppNode } from "config";
 
-export function CanvasBuilder() {
+const selector = (state: AppState) => ({
+	nodes: state.nodes,
+	edges: state.edges,
+	onNodesChange: state.onNodesChange,
+	onEdgesChange: state.onEdgesChange,
+	onConnect: state.onConnect,
+});
+
+export function Canvas({ className }: { className?: string }) {
+	const { nodes, edges, onNodesChange, onEdgesChange, onConnect } = useStateStore(useShallow(selector));
 	const store = useStateStore();
+	const instance = useReactFlow();
+	const flow = useStoreApi();
+	const viewport = useZoom();
+	useEffect(() => {
+		store.setViewport({
+			width: flow.getState().width,
+			height: flow.getState().height,
+			transformX: flow.getState().transform[0],
+			transformY: flow.getState().transform[1],
+			transformZoom: flow.getState().transform[2],
+		});
+	}, [store, flow]);
+	useEffect(() => {
+		instance.setViewport(viewport);
+	}, [viewport, instance]);
+	const nodeTypes = useMemo(
+		() => ({
+			network: NodeNetwork,
+			app: NodeApp,
+			"gateway-https": NodeGatewayHttps,
+			"gateway-tcp": NodeGatewayTCP,
+			volume: NodeVolume,
+			postgresql: NodePostgreSQL,
+			mongodb: NodeMongoDB,
+			github: NodeGithub,
+		}),
+		[],
+	);
+	const isValidConnection = useCallback(
+		(c: Edge | Connection) => {
+			if (c.source === c.target) {
+				return false;
+			}
+			const sn = instance.getNode(c.source)! as AppNode;
+			const tn = instance.getNode(c.target)! as AppNode;
+
+			if (sn.type === "github") {
+				return c.targetHandle === "repository";
+			}
+			if (sn.type === "app") {
+				if (c.sourceHandle === "ports" && (!sn.data.ports || sn.data.ports.length === 0)) {
+					return false;
+				}
+			}
+			if (tn.type === "gateway-https") {
+				if (c.targetHandle === "https" && tn.data.https !== undefined) {
+					return false;
+				}
+			}
+			if (sn.type === "volume") {
+				if (c.targetHandle !== "volume") {
+					return false;
+				}
+				return true;
+			}
+			if (tn.type === "network") {
+				if (c.sourceHandle !== "subdomain") {
+					return false;
+				}
+				if (sn.type !== "gateway-https" && sn.type !== "gateway-tcp") {
+					return false;
+				}
+			}
+			return true;
+		},
+		[instance],
+	);
 	return (
-		<ResizablePanelGroup direction="horizontal" className="w-full h-full">
-			<ResizablePanel defaultSize={80}>
-				<ResizablePanelGroup direction="vertical">
-					<ResizablePanel defaultSize={80}>
-						<ResizablePanelGroup direction="horizontal">
-							{store.mode === "edit" && (
-								<>
-									<ResizablePanel defaultSize={15}>
-										<Resources />
-									</ResizablePanel>
-									<ResizableHandle withHandle />
-								</>
-							)}
-							<ResizablePanel defaultSize={store.mode === "edit" ? 85 : 100}>
-								<Canvas />
-							</ResizablePanel>
-						</ResizablePanelGroup>
-					</ResizablePanel>
-					<ResizableHandle withHandle />
-					<ResizablePanel defaultSize={20}>
-						<Tools />
-					</ResizablePanel>
-				</ResizablePanelGroup>
-			</ResizablePanel>
-			<ResizableHandle withHandle />
-			<ResizablePanel defaultSize={20} className="!overflow-y-auto !overflow-x-hidden">
-				<Details />
-			</ResizablePanel>
-		</ResizablePanelGroup>
+		<div style={{ width: "100%", height: "100%" }} className={className}>
+			<ReactFlow
+				nodeTypes={nodeTypes}
+				nodes={nodes}
+				edges={edges}
+				onNodesChange={onNodesChange}
+				onEdgesChange={onEdgesChange}
+				onConnect={onConnect}
+				isValidConnection={isValidConnection}
+				fitView
+				proOptions={{ hideAttribution: true }}
+			>
+				<Controls showInteractive={false} />
+				<Background
+					variant={store.mode === "deploy" ? BackgroundVariant.Dots : BackgroundVariant.Lines}
+					gap={12}
+					size={1}
+				/>
+				<Panel position="top-right">
+					<Actions />
+				</Panel>
+			</ReactFlow>
+		</div>
 	);
 }
+
+export default Canvas;
diff --git a/apps/canvas/front/src/Overview.tsx b/apps/canvas/front/src/Overview.tsx
index 1c86aae..58c4d79 100644
--- a/apps/canvas/front/src/Overview.tsx
+++ b/apps/canvas/front/src/Overview.tsx
@@ -1,10 +1,9 @@
 import React, { useMemo } from "react";
-import { useStateStore, useLeadAgent } from "@/lib/state";
+import { useStateStore } from "@/lib/state";
 import { NodeDetails } from "./components/node-details";
 import { Actions } from "./components/actions";
-import { Canvas } from "./components/canvas";
+import { Canvas } from "./Canvas";
 import { Separator } from "./components/ui/separator";
-import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./components/ui/resizable";
 import { AppNode, NodeType } from "config";
 
 const sections: { title: string; nodes: NodeType[] }[] = [
@@ -23,30 +22,6 @@
 ];
 
 export function Overview(): React.ReactNode {
-	const leadAgent = useLeadAgent();
-	if (leadAgent) {
-		return (
-			<ResizablePanelGroup direction="horizontal" className="w-full h-full">
-				<ResizablePanel defaultSize={25}>
-					<iframe
-						key={leadAgent.name}
-						src={`${leadAgent.address}?m`}
-						title={leadAgent.agentName}
-						className="w-full h-full"
-					/>
-				</ResizablePanel>
-				<ResizableHandle withHandle />
-				<ResizablePanel defaultSize={75} className="!overflow-y-auto !overflow-x-hidden">
-					<OverviewImpl />
-				</ResizablePanel>
-			</ResizablePanelGroup>
-		);
-	} else {
-		return <OverviewImpl />;
-	}
-}
-
-function OverviewImpl(): React.ReactNode {
 	const store = useStateStore();
 	const nodes = useMemo(() => {
 		return store.nodes.filter((n) => n.type !== "network" && n.type !== "github" && n.type !== undefined);
@@ -63,7 +38,7 @@
 	return (
 		<div className="h-full w-full overflow-auto bg-white p-2">
 			<div className="w-full flex flex-row justify-end">
-				<Actions isOverview={true} />
+				<Actions />
 				<Canvas className="hidden" />
 			</div>
 			<div className="flex flex-col gap-4 pt-2">
diff --git a/apps/canvas/front/src/components/actions.tsx b/apps/canvas/front/src/components/actions.tsx
index f283ec8..3047fdf 100644
--- a/apps/canvas/front/src/components/actions.tsx
+++ b/apps/canvas/front/src/components/actions.tsx
@@ -26,11 +26,7 @@
 	}
 }
 
-interface ActionsProps {
-	isOverview?: boolean;
-}
-
-export function Actions({ isOverview = false }: ActionsProps) {
+export function Actions() {
 	const { toast } = useToast();
 	const store = useStateStore();
 	const projectId = useProjectId();
@@ -275,6 +271,12 @@
 	if (store.mode === "deploy") {
 		return (
 			<div className="flex flex-row gap-1 items-center">
+				<Button
+					onClick={() => store.setBuildMode(store.buildMode === "overview" ? "canvas" : "overview")}
+					{...reloadProps}
+				>
+					{store.buildMode === "overview" ? "Canvas" : "Overview"}
+				</Button>
 				<Button onClick={edit} {...reloadProps}>
 					Edit
 				</Button>
@@ -323,12 +325,13 @@
 		return (
 			<>
 				<div className="flex flex-row gap-1 items-center">
-					{isOverview && (
-						<Button onClick={() => setShowResourcesModal(true)}>
-							<Plus className="w-4 h-4 mr-1" />
-							Add
-						</Button>
-					)}
+					<Button onClick={() => store.setBuildMode(store.buildMode === "overview" ? "canvas" : "overview")}>
+						{store.buildMode === "overview" ? "Canvas" : "Overview"}
+					</Button>
+					<Button onClick={() => setShowResourcesModal(true)}>
+						<Plus className="w-4 h-4 mr-1" />
+						Add
+					</Button>
 					<Button onClick={deploy} {...deployProps}>
 						{deployProps.loading ? (
 							<>
diff --git a/apps/canvas/front/src/components/canvas.tsx b/apps/canvas/front/src/components/canvas.tsx
deleted file mode 100644
index 2d78abd..0000000
--- a/apps/canvas/front/src/components/canvas.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-import "@xyflow/react/dist/style.css";
-import {
-	ReactFlow,
-	Background,
-	Controls,
-	Connection,
-	BackgroundVariant,
-	Edge,
-	useReactFlow,
-	Panel,
-	useStoreApi,
-} from "@xyflow/react";
-import { useStateStore, AppState, useZoom } from "@/lib/state";
-import { useShallow } from "zustand/react/shallow";
-import { useCallback, useEffect, useMemo } from "react";
-import { NodeGatewayHttps } from "@/components/node-gateway-https";
-import { NodeApp } from "@/components/node-app";
-import { NodeVolume } from "./node-volume";
-import { NodePostgreSQL } from "./node-postgresql";
-import { NodeMongoDB } from "./node-mongodb";
-import { NodeGithub } from "./node-github";
-import { Actions } from "./actions";
-import { NodeGatewayTCP } from "./node-gateway-tcp";
-import { NodeNetwork } from "./node-network";
-import { AppNode } from "config";
-
-const selector = (state: AppState) => ({
-	nodes: state.nodes,
-	edges: state.edges,
-	onNodesChange: state.onNodesChange,
-	onEdgesChange: state.onEdgesChange,
-	onConnect: state.onConnect,
-});
-
-export function Canvas({ className }: { className?: string }) {
-	const { nodes, edges, onNodesChange, onEdgesChange, onConnect } = useStateStore(useShallow(selector));
-	const store = useStateStore();
-	const instance = useReactFlow();
-	const flow = useStoreApi();
-	const viewport = useZoom();
-	useEffect(() => {
-		store.setViewport({
-			width: flow.getState().width,
-			height: flow.getState().height,
-			transformX: flow.getState().transform[0],
-			transformY: flow.getState().transform[1],
-			transformZoom: flow.getState().transform[2],
-		});
-	}, [store, flow]);
-	useEffect(() => {
-		instance.setViewport(viewport);
-	}, [viewport, instance]);
-	const nodeTypes = useMemo(
-		() => ({
-			network: NodeNetwork,
-			app: NodeApp,
-			"gateway-https": NodeGatewayHttps,
-			"gateway-tcp": NodeGatewayTCP,
-			volume: NodeVolume,
-			postgresql: NodePostgreSQL,
-			mongodb: NodeMongoDB,
-			github: NodeGithub,
-		}),
-		[],
-	);
-	const isValidConnection = useCallback(
-		(c: Edge | Connection) => {
-			if (c.source === c.target) {
-				return false;
-			}
-			const sn = instance.getNode(c.source)! as AppNode;
-			const tn = instance.getNode(c.target)! as AppNode;
-
-			if (sn.type === "github") {
-				return c.targetHandle === "repository";
-			}
-			if (sn.type === "app") {
-				if (c.sourceHandle === "ports" && (!sn.data.ports || sn.data.ports.length === 0)) {
-					return false;
-				}
-			}
-			if (tn.type === "gateway-https") {
-				if (c.targetHandle === "https" && tn.data.https !== undefined) {
-					return false;
-				}
-			}
-			if (sn.type === "volume") {
-				if (c.targetHandle !== "volume") {
-					return false;
-				}
-				return true;
-			}
-			if (tn.type === "network") {
-				if (c.sourceHandle !== "subdomain") {
-					return false;
-				}
-				if (sn.type !== "gateway-https" && sn.type !== "gateway-tcp") {
-					return false;
-				}
-			}
-			return true;
-		},
-		[instance],
-	);
-	return (
-		<div style={{ width: "100%", height: "100%" }} className={className}>
-			<ReactFlow
-				nodeTypes={nodeTypes}
-				nodes={nodes}
-				edges={edges}
-				onNodesChange={onNodesChange}
-				onEdgesChange={onEdgesChange}
-				onConnect={onConnect}
-				isValidConnection={isValidConnection}
-				fitView
-				proOptions={{ hideAttribution: true }}
-			>
-				<Controls showInteractive={false} />
-				<Background
-					variant={store.mode === "deploy" ? BackgroundVariant.Dots : BackgroundVariant.Lines}
-					gap={12}
-					size={1}
-				/>
-				<Panel position="top-right">
-					<Actions />
-				</Panel>
-			</ReactFlow>
-		</div>
-	);
-}
-
-export default Canvas;
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index 5e9315b..b0237a9 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -196,6 +196,7 @@
 export type AppState = {
 	projectId: string | undefined;
 	mode: "edit" | "deploy";
+	buildMode: "overview" | "canvas";
 	projects: Project[];
 	nodes: AppNode[];
 	edges: Edge[];
@@ -219,6 +220,7 @@
 	setEdges: (edges: Edge[]) => void;
 	setProject: (projectId: string | undefined) => Promise<void>;
 	setMode: (mode: "edit" | "deploy") => void;
+	setBuildMode: (buildMode: "overview" | "canvas") => void;
 	updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
 	updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
 	replaceEdge: (c: Connection, id?: string) => void;
@@ -234,6 +236,7 @@
 const githubRepositoriesSelector = (state: AppState) => state.githubRepositories;
 const githubRepositoriesLoadingSelector = (state: AppState) => state.githubRepositoriesLoading;
 const githubRepositoriesErrorSelector = (state: AppState) => state.githubRepositoriesError;
+const buildModeSelector = (state: AppState) => state.buildMode;
 
 export function useZoom(): ReactFlowViewport {
 	return useStateStore(zoomSelector);
@@ -314,6 +317,10 @@
 	return useStateStore((state) => state.mode);
 }
 
+export function useBuildMode(): "overview" | "canvas" {
+	return useStateStore(buildModeSelector);
+}
+
 const v: Validator = CreateValidators();
 
 function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
@@ -626,13 +633,7 @@
 			setN(inst.nodes);
 			set({ edges: inst.edges });
 			injectNetworkNodes();
-			if (
-				get().zoom.x !== inst.viewport.x ||
-				get().zoom.y !== inst.viewport.y ||
-				get().zoom.zoom !== inst.viewport.zoom
-			) {
-				set({ zoom: inst.viewport });
-			}
+			// TODO(gio): set viewport
 		};
 
 		eventSource.onerror = (err) => {
@@ -645,6 +646,7 @@
 	return {
 		projectId: undefined,
 		mode: "edit",
+		buildMode: "overview",
 		projects: [],
 		nodes: [],
 		edges: [],
@@ -801,6 +803,9 @@
 				connectToStateStream(projectId, mode);
 			}
 		},
+		setBuildMode: (buildMode) => {
+			set({ buildMode });
+		},
 		setProject: async (projectId) => {
 			const currentProjectId = get().projectId;
 			if (projectId === currentProjectId) {