diff --git a/package-lock.json b/package-lock.json index 70b6897..eecd282 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { "name": "odo", - "version": "0.10.0", + "version": "0.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "odo", - "version": "0.10.0", + "version": "0.10.1", "license": "MIT", "dependencies": { + "@types/react-router-dom": "^5.3.3", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-router-dom": "^7.13.1" }, "devDependencies": { "@types/react": "^19.0.0", @@ -2012,6 +2014,12 @@ "@types/node": "*" } }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "license": "MIT" + }, "node_modules/@types/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -2062,7 +2070,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2078,6 +2085,27 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, "node_modules/@types/responselike": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", @@ -3414,6 +3442,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -3493,7 +3534,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -6531,6 +6571,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/read-binary-file-arch": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", @@ -6941,6 +7019,12 @@ "dev": true, "license": "ISC" }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index d6a6703..31075ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "odo", - "version": "0.10.1", + "version": "0.11.0", "description": "ODO — OpenClaw Dashboard Orchestrator", "main": "main.js", "scripts": { @@ -24,15 +24,21 @@ ], "mac": { "category": "public.app-category.developer-tools", - "target": ["dmg"], + "target": [ + "dmg" + ], "icon": "assets/icon.icns" }, "win": { - "target": ["nsis"], + "target": [ + "nsis" + ], "icon": "assets/icon.ico" }, "linux": { - "target": ["AppImage"], + "target": [ + "AppImage" + ], "icon": "assets/icon.png", "category": "Development" }, @@ -44,8 +50,10 @@ "author": "", "license": "MIT", "dependencies": { + "@types/react-router-dom": "^5.3.3", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-router-dom": "^7.13.1" }, "devDependencies": { "@types/react": "^19.0.0", diff --git a/preload.js b/preload.js index f46176e..089d720 100644 --- a/preload.js +++ b/preload.js @@ -4,7 +4,7 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('odo', { platform: process.platform, - version: '0.10.1', + version: '0.11.0', isElectron: true, gateway: { diff --git a/src/App.tsx b/src/App.tsx index f18b6e6..bd46127 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,19 @@ +import { HashRouter, Routes, Route } from 'react-router-dom'; +import Layout from './components/Layout'; import Dashboard from './pages/Dashboard'; +import Agents from './pages/Agents'; function App() { - // Router placeholder — will add react-router in a future phase - return ; + return ( + + + }> + } /> + } /> + + + + ); } export default App; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx new file mode 100644 index 0000000..c490d42 --- /dev/null +++ b/src/components/Layout.tsx @@ -0,0 +1,66 @@ +import { NavLink, Outlet } from 'react-router-dom'; + +const navItems = [ + { to: '/', label: 'Dashboard', icon: '🏠' }, + { to: '/agents', label: 'Agents', icon: '🤖' }, +]; + +function Layout() { + const isElectron = typeof window !== 'undefined' && window.odo?.isElectron; + const version = window.odo?.version ?? '0.11.0'; + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ +
+
+ ); +} + +export default Layout; diff --git a/src/pages/Agents.tsx b/src/pages/Agents.tsx new file mode 100644 index 0000000..ecfb606 --- /dev/null +++ b/src/pages/Agents.tsx @@ -0,0 +1,433 @@ +import { useState, useEffect, useCallback } from 'react'; + +// --- Types --- + +interface Agent { + agentId: string; + displayName: string; + model: string; + parentAgentId?: string; + workspace?: string; + channels?: string[]; +} + +interface WorkspaceFile { + name: string; + content: string; + type?: string; +} + +type AgentsState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'loaded'; agents: Agent[] } + | { status: 'error'; message: string }; + +type WorkspaceState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'loaded'; files: WorkspaceFile[] } + | { status: 'error'; message: string }; + +const MODEL_OPTIONS = [ + 'claude-sonnet-4-20250514', + 'claude-opus-4-20250514', + 'claude-haiku-4-5-20251001', +]; + +// --- Component --- + +function Agents() { + const [agentsState, setAgentsState] = useState({ status: 'idle' }); + const [expandedAgents, setExpandedAgents] = useState>(new Set()); + const [selectedAgent, setSelectedAgent] = useState(null); + const [workspace, setWorkspace] = useState({ status: 'idle' }); + const [viewingFile, setViewingFile] = useState(null); + const [editing, setEditing] = useState(false); + const [editContent, setEditContent] = useState(''); + const [saving, setSaving] = useState(false); + + const isElectron = typeof window !== 'undefined' && window.odo?.isElectron; + + // Fetch agents + const fetchAgents = useCallback(async () => { + if (!isElectron) { + setAgentsState({ status: 'error', message: 'Not running in Electron' }); + return; + } + setAgentsState({ status: 'loading' }); + try { + const data = await window.odo.ocm.api('GET', '/api/agents'); + const agents = (data as { agents: Agent[] }).agents ?? []; + setAgentsState({ status: 'loaded', agents }); + } catch (err) { + setAgentsState({ status: 'error', message: String(err) }); + } + }, [isElectron]); + + useEffect(() => { + fetchAgents(); + }, [fetchAgents]); + + // Fetch workspace files for an agent + const fetchWorkspace = useCallback( + async (agentId: string) => { + if (!isElectron) return; + setWorkspace({ status: 'loading' }); + setViewingFile(null); + setEditing(false); + try { + const data = await window.odo.ocm.api('GET', `/api/workspace/${agentId}`); + const files = (data as { files: WorkspaceFile[] }).files ?? []; + setWorkspace({ status: 'loaded', files }); + } catch (err) { + setWorkspace({ status: 'error', message: String(err) }); + } + }, + [isElectron], + ); + + // Toggle sub-agent expansion + const toggleExpand = (agentId: string) => { + setExpandedAgents((prev) => { + const next = new Set(prev); + if (next.has(agentId)) next.delete(agentId); + else next.add(agentId); + return next; + }); + }; + + // Select agent to view workspace + const handleSelectAgent = (agentId: string) => { + if (selectedAgent === agentId) { + setSelectedAgent(null); + setWorkspace({ status: 'idle' }); + setViewingFile(null); + return; + } + setSelectedAgent(agentId); + fetchWorkspace(agentId); + }; + + // Update model + const handleModelChange = async (agentId: string, model: string) => { + if (!isElectron) return; + try { + await window.odo.ocm.api('PUT', `/api/agents/${agentId}`, { model }); + // Update local state + setAgentsState((prev) => { + if (prev.status !== 'loaded') return prev; + return { + ...prev, + agents: prev.agents.map((a) => + a.agentId === agentId ? { ...a, model } : a, + ), + }; + }); + } catch (err) { + console.error('Failed to update model:', err); + } + }; + + // Save file + const handleSave = async () => { + if (!isElectron || !selectedAgent || !viewingFile) return; + setSaving(true); + try { + await window.odo.ocm.api('PUT', `/api/workspace/${selectedAgent}`, { + file: viewingFile.name, + content: editContent, + }); + // Update local state + setViewingFile({ ...viewingFile, content: editContent }); + setEditing(false); + // Refresh workspace + fetchWorkspace(selectedAgent); + } catch (err) { + console.error('Failed to save file:', err); + } finally { + setSaving(false); + } + }; + + // Build agent tree + const getMainAgents = (): Agent[] => { + if (agentsState.status !== 'loaded') return []; + return agentsState.agents.filter((a) => !a.parentAgentId); + }; + + const getSubAgents = (parentId: string): Agent[] => { + if (agentsState.status !== 'loaded') return []; + return agentsState.agents.filter((a) => a.parentAgentId === parentId); + }; + + // --- Render helpers --- + + const renderAgentCard = (agent: Agent, isSubAgent = false) => { + const subAgents = getSubAgents(agent.agentId); + const isExpanded = expandedAgents.has(agent.agentId); + const isSelected = selectedAgent === agent.agentId; + + return ( +
+
+
+
+
+ {subAgents.length > 0 && ( + + )} +

+ {agent.displayName || agent.agentId} +

+ {isSubAgent && ( + + sub-agent + + )} +
+

+ {agent.agentId} +

+ {agent.workspace && ( +

+ 📁 {agent.workspace} +

+ )} + {subAgents.length > 0 && ( +

+ {subAgents.length} sub-agent{subAgents.length !== 1 ? 's' : ''} +

+ )} +
+ +
+ {/* Model selector */} + + + {/* Workspace button */} + +
+
+ + {/* Workspace file browser */} + {isSelected && ( +
+ {workspace.status === 'loading' && ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ )} + + {workspace.status === 'error' && ( +

+ Failed to load workspace: {workspace.message} +

+ )} + + {workspace.status === 'loaded' && ( +
+ {workspace.files.length === 0 ? ( +

No workspace files found.

+ ) : ( +
+ {/* File list */} +
+ {workspace.files.map((f) => ( + + ))} +
+ + {/* File content */} + {viewingFile && ( +
+
+ + {viewingFile.name} + +
+ {editing ? ( + <> + + + + ) : ( + + )} +
+
+ + {editing ? ( +