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:
2026-03-20 21:37:27 +10:00
co-authored by Claude Opus 4.6
parent 7c107471cf
commit 8cb243266c
6 changed files with 615 additions and 13 deletions
+89 -5
View File
@@ -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",
+13 -5
View File
@@ -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",
+1 -1
View File
@@ -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: {
+13 -2
View File
@@ -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 <Dashboard />;
return (
<HashRouter>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/agents" element={<Agents />} />
</Route>
</Routes>
</HashRouter>
);
}
export default App;
+66
View File
@@ -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;
+433
View File
@@ -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;