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) {