mirror of
https://github.com/dtzp555-max/odo.git
synced 2026-07-21 21:15:10 +00:00
feat: add navigation sidebar + Agents Console (v0.11.0)
Add react-router-dom with HashRouter, persistent Layout with sidebar navigation (Dashboard + Agents), and Agents Console page with agent tree view, model selector, and workspace file browser with edit/save. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+89
-5
@@ -1,16 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "odo",
|
"name": "odo",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "odo",
|
"name": "odo",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.13.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
@@ -2012,6 +2014,12 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/http-cache-semantics": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||||
@@ -2062,7 +2070,6 @@
|
|||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -2078,6 +2085,27 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@types/responselike": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
|
||||||
@@ -3414,6 +3442,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/core-util-is": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
|
||||||
@@ -3493,7 +3534,6 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
@@ -6531,6 +6571,44 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/read-binary-file-arch": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
|
||||||
@@ -6941,6 +7019,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|||||||
+13
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "odo",
|
"name": "odo",
|
||||||
"version": "0.10.1",
|
"version": "0.11.0",
|
||||||
"description": "ODO — OpenClaw Dashboard Orchestrator",
|
"description": "ODO — OpenClaw Dashboard Orchestrator",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -24,15 +24,21 @@
|
|||||||
],
|
],
|
||||||
"mac": {
|
"mac": {
|
||||||
"category": "public.app-category.developer-tools",
|
"category": "public.app-category.developer-tools",
|
||||||
"target": ["dmg"],
|
"target": [
|
||||||
|
"dmg"
|
||||||
|
],
|
||||||
"icon": "assets/icon.icns"
|
"icon": "assets/icon.icns"
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": ["nsis"],
|
"target": [
|
||||||
|
"nsis"
|
||||||
|
],
|
||||||
"icon": "assets/icon.ico"
|
"icon": "assets/icon.ico"
|
||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"target": ["AppImage"],
|
"target": [
|
||||||
|
"AppImage"
|
||||||
|
],
|
||||||
"icon": "assets/icon.png",
|
"icon": "assets/icon.png",
|
||||||
"category": "Development"
|
"category": "Development"
|
||||||
},
|
},
|
||||||
@@ -44,8 +50,10 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.13.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ const { contextBridge, ipcRenderer } = require('electron');
|
|||||||
|
|
||||||
contextBridge.exposeInMainWorld('odo', {
|
contextBridge.exposeInMainWorld('odo', {
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
version: '0.10.1',
|
version: '0.11.0',
|
||||||
isElectron: true,
|
isElectron: true,
|
||||||
|
|
||||||
gateway: {
|
gateway: {
|
||||||
|
|||||||
+13
-2
@@ -1,8 +1,19 @@
|
|||||||
|
import { HashRouter, Routes, Route } from 'react-router-dom';
|
||||||
|
import Layout from './components/Layout';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import Agents from './pages/Agents';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// Router placeholder — will add react-router in a future phase
|
return (
|
||||||
return <Dashboard />;
|
<HashRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/agents" element={<Agents />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</HashRouter>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex h-screen bg-odo-bg text-odo-text">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-[200px] flex-shrink-0 bg-odo-surface border-r border-odo-border flex flex-col">
|
||||||
|
{/* App title */}
|
||||||
|
<div className="px-4 py-4 border-b border-odo-border">
|
||||||
|
<h1 className="text-xl font-bold text-white tracking-tight">ODO</h1>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span
|
||||||
|
className={`inline-block w-2 h-2 rounded-full ${
|
||||||
|
isElectron ? 'bg-green-500' : 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-odo-text-dim text-xs">
|
||||||
|
{isElectron ? 'Connected' : 'No gateway'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 py-2">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.to === '/'}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-4 py-3 text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-odo-accent/15 text-odo-accent border-r-2 border-odo-accent'
|
||||||
|
: 'text-odo-text-dim hover:text-odo-text hover:bg-odo-bg/50'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-lg">{item.icon}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Version footer */}
|
||||||
|
<div className="px-4 py-3 border-t border-odo-border">
|
||||||
|
<p className="text-odo-text-dim text-xs">v{version}</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 overflow-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -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<AgentsState>({ status: 'idle' });
|
||||||
|
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(new Set());
|
||||||
|
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
|
||||||
|
const [workspace, setWorkspace] = useState<WorkspaceState>({ status: 'idle' });
|
||||||
|
const [viewingFile, setViewingFile] = useState<WorkspaceFile | null>(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 (
|
||||||
|
<div key={agent.agentId} className={isSubAgent ? 'ml-6' : ''}>
|
||||||
|
<div
|
||||||
|
className={`bg-odo-surface border rounded-lg p-4 mb-2 transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'border-odo-accent'
|
||||||
|
: 'border-odo-border hover:border-odo-text-dim'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{subAgents.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => toggleExpand(agent.agentId)}
|
||||||
|
className="text-odo-text-dim hover:text-odo-text text-sm"
|
||||||
|
>
|
||||||
|
{isExpanded ? '▼' : '▶'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<h3 className="text-white font-medium truncate">
|
||||||
|
{agent.displayName || agent.agentId}
|
||||||
|
</h3>
|
||||||
|
{isSubAgent && (
|
||||||
|
<span className="text-xs bg-odo-border px-2 py-0.5 rounded text-odo-text-dim">
|
||||||
|
sub-agent
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-odo-text-dim text-xs mt-1 font-mono truncate">
|
||||||
|
{agent.agentId}
|
||||||
|
</p>
|
||||||
|
{agent.workspace && (
|
||||||
|
<p className="text-odo-text-dim text-xs mt-1 truncate">
|
||||||
|
📁 {agent.workspace}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{subAgents.length > 0 && (
|
||||||
|
<p className="text-odo-text-dim text-xs mt-1">
|
||||||
|
{subAgents.length} sub-agent{subAgents.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
|
{/* Model selector */}
|
||||||
|
<select
|
||||||
|
value={agent.model}
|
||||||
|
onChange={(e) => handleModelChange(agent.agentId, e.target.value)}
|
||||||
|
className="bg-odo-bg border border-odo-border rounded px-2 py-1 text-xs text-odo-text focus:outline-none focus:border-odo-accent"
|
||||||
|
>
|
||||||
|
{MODEL_OPTIONS.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{/* Show current model even if not in presets */}
|
||||||
|
{!MODEL_OPTIONS.includes(agent.model) && (
|
||||||
|
<option value={agent.model}>{agent.model}</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Workspace button */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleSelectAgent(agent.agentId)}
|
||||||
|
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-odo-accent text-white'
|
||||||
|
: 'bg-odo-border text-odo-text hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected ? 'Close' : 'Files'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workspace file browser */}
|
||||||
|
{isSelected && (
|
||||||
|
<div className="mt-4 border-t border-odo-border pt-4">
|
||||||
|
{workspace.status === 'loading' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-8 bg-odo-bg border border-odo-border rounded animate-pulse"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{workspace.status === 'error' && (
|
||||||
|
<p className="text-odo-text-dim text-sm">
|
||||||
|
Failed to load workspace: {workspace.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{workspace.status === 'loaded' && (
|
||||||
|
<div>
|
||||||
|
{workspace.files.length === 0 ? (
|
||||||
|
<p className="text-odo-text-dim text-sm">No workspace files found.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* File list */}
|
||||||
|
<div className="w-48 flex-shrink-0 space-y-1">
|
||||||
|
{workspace.files.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.name}
|
||||||
|
onClick={() => {
|
||||||
|
setViewingFile(f);
|
||||||
|
setEditing(false);
|
||||||
|
setEditContent(f.content);
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-3 py-2 rounded text-xs font-mono transition-colors ${
|
||||||
|
viewingFile?.name === f.name
|
||||||
|
? 'bg-odo-accent/15 text-odo-accent'
|
||||||
|
: 'text-odo-text-dim hover:text-odo-text hover:bg-odo-bg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File content */}
|
||||||
|
{viewingFile && (
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs text-odo-text-dim font-mono">
|
||||||
|
{viewingFile.name}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(false)}
|
||||||
|
className="px-2 py-1 text-xs text-odo-text-dim hover:text-odo-text"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-3 py-1 bg-odo-accent hover:bg-odo-accent-hover disabled:opacity-50 text-white rounded text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(true);
|
||||||
|
setEditContent(viewingFile.content);
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 bg-odo-border hover:bg-gray-600 text-white rounded text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<textarea
|
||||||
|
value={editContent}
|
||||||
|
onChange={(e) => setEditContent(e.target.value)}
|
||||||
|
className="w-full h-64 bg-odo-bg border border-odo-border rounded p-3 text-xs text-odo-text font-mono resize-y focus:outline-none focus:border-odo-accent"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<pre className="bg-odo-bg border border-odo-border rounded p-3 text-xs text-odo-text-dim overflow-auto whitespace-pre-wrap max-h-64">
|
||||||
|
{viewingFile.content}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sub-agents */}
|
||||||
|
{isExpanded &&
|
||||||
|
subAgents.map((sub) => renderAgentCard(sub, true))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Main render ---
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-odo-bg p-6">
|
||||||
|
<header className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white tracking-tight">
|
||||||
|
Agents Console
|
||||||
|
</h1>
|
||||||
|
<p className="text-odo-text-dim text-sm mt-1">
|
||||||
|
Manage agents, models, and workspace files
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchAgents}
|
||||||
|
disabled={agentsState.status === 'loading'}
|
||||||
|
className="px-4 py-2 bg-odo-border hover:bg-gray-600 disabled:opacity-50 text-white rounded text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{agentsState.status === 'loading' ? 'Loading...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Loading skeleton */}
|
||||||
|
{(agentsState.status === 'idle' || agentsState.status === 'loading') && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bg-odo-surface border border-odo-border rounded-lg p-4 animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="h-5 bg-odo-border rounded w-48 mb-2" />
|
||||||
|
<div className="h-4 bg-odo-border rounded w-72" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{agentsState.status === 'error' && (
|
||||||
|
<div className="bg-odo-surface border border-odo-border rounded-lg p-6 text-center">
|
||||||
|
<p className="text-odo-text-dim text-sm mb-4">
|
||||||
|
Unable to load agents — OCM server may not be running.
|
||||||
|
</p>
|
||||||
|
<p className="text-odo-text-dim text-xs mb-4">{agentsState.message}</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchAgents}
|
||||||
|
className="px-4 py-2 bg-odo-border hover:bg-gray-600 text-white rounded text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Agent list */}
|
||||||
|
{agentsState.status === 'loaded' && (
|
||||||
|
<div>
|
||||||
|
{agentsState.agents.length === 0 ? (
|
||||||
|
<div className="bg-odo-surface border border-odo-border rounded-lg p-6 text-center">
|
||||||
|
<p className="text-odo-text-dim text-sm">
|
||||||
|
No agents found. Start an agent via OpenClaw to see it here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{getMainAgents().map((agent) => renderAgentCard(agent))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Agents;
|
||||||
Reference in New Issue
Block a user