feat(opax-ui): make Emma a modern operator workspace

This commit is contained in:
Chris Christiansen 2026-09-13 16:26:04 +00:00
parent 8f1c501651
commit c646649a76
11 changed files with 210 additions and 345 deletions

View File

@ -101,4 +101,13 @@ router.get('/capabilities', (req, res) => {
res.json(capabilities);
});
router.get('/emma/status', async (req, res) => {
try {
await callAgent('get_health', {});
res.json({ status: 'ready', label: 'Emma Local', message: 'Emma is ready.' });
} catch (e) {
res.status(500).json({ status: 'unavailable', label: 'Emma Local', message: 'Emma is unavailable.' });
}
});
export default router;

View File

@ -1,75 +0,0 @@
import React, { useState } from 'react';
const AgentConsoleView = () => {
const [message, setMessage] = useState('');
const [transcript, setTranscript] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [ticketNumber, setTicketNumber] = useState(null);
const handleSend = async () => {
if (!message.trim()) return;
const newMessage = { role: 'user', content: message };
setTranscript(prev => [...prev, newMessage]);
setMessage('');
setLoading(true);
setError(null);
try {
const res = await fetch('/api/console/emma/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ message, ticket_number: ticketNumber }),
});
if (!res.ok) {
throw new Error('Emma is unavailable.');
}
const data = await res.json();
const emmaMessage = { role: 'assistant', content: data.reply };
setTranscript(prev => [...prev, emmaMessage]);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="console-card">
<h2>Emma Local</h2>
<div className="agent-info">
<span>Local model · server-side memory enabled</span>
{ticketNumber && <span>Working context: Gitea #{ticketNumber}</span>}
</div>
<div className="transcript">
{transcript.map((msg, index) => (
<div key={index} className={`message ${msg.role}`}>
<strong>{msg.role}: </strong>{msg.content}
</div>
))}
{loading && <div className="message assistant">...</div>}
{error && <div className="message error">{error}</div>}
</div>
<div className="chat-input">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSend()}
placeholder="Chat with Emma..."
disabled={loading}
/>
<button onClick={handleSend} disabled={loading}>
{loading ? 'Sending...' : 'Send'}
</button>
</div>
</div>
);
};
export default AgentConsoleView;

View File

@ -1,12 +0,0 @@
import React from 'react';
const ApprovalsView = () => {
return (
<div className="console-card">
<h2>Approvals</h2>
<p className="unavailable-message">No approval read model configured yet.</p>
</div>
);
};
export default ApprovalsView;

View File

@ -1,7 +1,16 @@
import React, { useState, useEffect } from 'react';
interface Capability {
type: string;
}
interface Capabilities {
[key: string]: Capability;
}
const CapabilityPanel = () => {
const [capabilities, setCapabilities] = useState({});
const [capabilities, setCapabilities] = useState<Capabilities>({});
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const fetchCapabilities = async () => {
@ -13,35 +22,36 @@ const CapabilityPanel = () => {
}, []);
const capabilityGroups = {
'Platform Status': ['get_health', 'get_build_status', 'get_state', 'get_telemetry'],
'Git and Gitea': ['list_commits', 'get_file'],
'Tickets and Work Queue': [],
'Code Agents': ['run_emma'],
'Tests and Validation': [],
'Builds and Deployments': ['trigger_build', 'commit_and_push_files'],
'Cloud Run and Infrastructure': [],
'Logs and Telemetry': [],
'Billing and CostGuard': ['get_billing_summary', 'get_billing_forecast', 'get_billing_anomalies', 'get_billing_budget', 'set_billing_budget'],
'Customers and Workspace': ['list_customers', 'list_workspace_users', 'create_invite'],
'Platform': ['get_health', 'get_build_status', 'get_state', 'get_telemetry'],
'Repository': ['list_commits', 'get_file'],
'Work and tickets': [],
'Code agents': ['run_emma'],
'Validation': [],
'Deployment': ['trigger_build', 'commit_and_push_files'],
'Costs': ['get_billing_summary', 'get_billing_forecast', 'get_billing_anomalies', 'get_billing_budget', 'set_billing_budget'],
'Notifications': ['send_email', 'send_sms', 'send_webhook'],
'Customers and Workspace': ['list_customers', 'list_workspace_users', 'create_invite'],
'Memory and Knowledge': [],
};
return (
<div className="capability-panel">
<h3>Capabilities</h3>
{Object.entries(capabilityGroups).map(([group, capabilityKeys]) => (
<div key={group} className="capability-group">
<h4>{group}</h4>
<ul>
{capabilityKeys.map(key => {
const capability = capabilities[key];
if (!capability) return null;
return <li key={key} className={capability.type}>{key}</li>
})}
</ul>
<div className={`capability-drawer ${isOpen ? 'open' : ''}`}>
<button onClick={() => setIsOpen(!isOpen)} className="drawer-toggle">Capabilities</button>
<div className="drawer-content">
<h3>Capabilities</h3>
{Object.entries(capabilityGroups).map(([group, capabilityKeys]) => (
<div key={group} className="capability-group">
<h4>{group}</h4>
<ul>
{capabilityKeys.map(key => {
const capability = capabilities[key];
if (!capability) return null;
return <li key={key} className={capability.type}>{key.replace(/_/g, ' ')}</li>
})}
</ul>
</div>
))}
</div>
))}
</div>
);
};

View File

@ -0,0 +1,105 @@
import React, { useState, useEffect } from 'react';
interface Message {
role: string;
content: string;
}
const ChatWorkspace = () => {
const [message, setMessage] = useState('');
const [transcript, setTranscript] = useState<Message[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [emmaStatus, setEmmaStatus] = useState<string>('unavailable');
const [ticketNumber, setTicketNumber] = useState<number | null>(null);
useEffect(() => {
const fetchEmmaStatus = async () => {
try {
const res = await fetch('/api/console/emma/status', { credentials: 'include' });
const data = await res.json();
setEmmaStatus(data.status);
} catch (e) {
setEmmaStatus('unavailable');
}
};
fetchEmmaStatus();
}, []);
const handleSend = async () => {
if (!message.trim()) return;
const newMessage: Message = { role: 'user', content: message };
setTranscript(prev => [...prev, newMessage]);
setMessage('');
setLoading(true);
setError(null);
try {
const res = await fetch('/api/console/emma/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ message, ticket_number: ticketNumber }),
});
if (!res.ok) {
throw new Error('Emma is unavailable.');
}
const data = await res.json();
const emmaMessage: Message = { role: 'assistant', content: data.reply };
setTranscript(prev => [...prev, emmaMessage]);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="chat-workspace">
<div className="chat-header">
<h3>Emma Local</h3>
<span>{emmaStatus === 'ready' ? 'Connected' : 'Unavailable'}</span>
{ticketNumber && <span className="ticket-chip">Gitea #{ticketNumber}</span>}
</div>
<div className="transcript">
{transcript.length === 0 && !loading && (
<div className="welcome-message">
<p>Hei Chris. Hva vil du at jeg skal gjort?</p>
<div className="quick-prompts">
<button>Status plattformen</button>
<button>Hva er neste oppgave?</button>
<button>Arbeid med Gitea #1</button>
<button>Sjekk siste build</button>
<button>Vis kostnadsavvik</button>
</div>
</div>
)}
{transcript.map((msg, index) => (
<div key={index} className={`message ${msg.role}`}>
<strong>{msg.role}: </strong>{msg.content}
</div>
))}
{loading && <div className="message assistant">...</div>}
{error && <div className="message error">{error} <button onClick={handleSend}>Retry</button></div>}
</div>
<div className="chat-composer">
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
placeholder="Chat with Emma..."
disabled={loading}
/>
<button onClick={handleSend} disabled={loading}>
{loading ? 'Sending...' : 'Send'}
</button>
</div>
</div>
);
};
export default ChatWorkspace;

View File

@ -0,0 +1,28 @@
import React from 'react';
import CapabilityPanel from './CapabilityPanel';
const ContextPanel = () => {
return (
<div className="context-panel">
<div className="current-task">
<h4>Current task</h4>
<div>Idle</div>
</div>
<div className="ticket-context">
<h4>Ticket context</h4>
<div>No active ticket</div>
</div>
<div className="suggested-actions">
<h4>Suggested actions</h4>
<ul>
<li>Check platform</li>
<li>Review Gitea #1</li>
<li>Check last deployment</li>
</ul>
</div>
<CapabilityPanel />
</div>
);
};
export default ContextPanel;

View File

@ -1,78 +0,0 @@
import React from 'react';
type ConsoleStatus = 'success' | 'unavailable' | 'loading';
type SafeHealthSummary = {
status: 'ok';
service: string;
version: string;
};
type ConsoleStatusItem = {
status: Exclude<ConsoleStatus, 'loading'>;
summary: SafeHealthSummary | null;
message: 'Available' | 'Unavailable';
};
interface ConsoleStatusResponse {
health?: ConsoleStatusItem;
buildStatus?: ConsoleStatusItem;
platformState?: ConsoleStatusItem;
}
interface StatusBlockProps {
title: string;
status: ConsoleStatus;
summary: SafeHealthSummary | null;
}
const StatusBlock: React.FC<StatusBlockProps> = ({ title, status, summary }) => {
return (
<div className={`status-block status-${status}`}>
<h3>{title}</h3>
{status === 'loading' && <p>Loading...</p>}
{status === 'unavailable' && <p>Unavailable</p>}
{status === 'success' && summary && (
<ul>
<li><strong>Status:</strong> {summary.status}</li>
<li><strong>Service:</strong> {summary.service}</li>
<li><strong>Version:</strong> {summary.version}</li>
</ul>
)}
</div>
);
};
interface PlatformStatusProps {
statusData: ConsoleStatusResponse | null;
loading: boolean;
}
const unavailableStatus: ConsoleStatus = 'unavailable';
const PlatformStatus: React.FC<PlatformStatusProps> = ({ statusData, loading }) => {
return (
<div className="console-card">
<h2>Platform Overview</h2>
<div className="status-container">
<StatusBlock
title="Health"
status={loading ? 'loading' : statusData?.health?.status ?? unavailableStatus}
summary={statusData?.health?.summary ?? null}
/>
<StatusBlock
title="Latest Build"
status={loading ? 'loading' : statusData?.buildStatus?.status ?? unavailableStatus}
summary={null} // Schema not confirmed, so always null for now
/>
<StatusBlock
title="Platform State"
status={loading ? 'loading' : statusData?.platformState?.status ?? unavailableStatus}
summary={null} // Schema not confirmed, so always null for now
/>
</div>
</div>
);
};
export default PlatformStatus;

View File

@ -1,12 +0,0 @@
import React from 'react';
const ProjectRegistryView = () => {
return (
<div className="console-card">
<h2>Projects</h2>
<p className="unavailable-message">No project registry configured yet.</p>
</div>
);
};
export default ProjectRegistryView;

View File

@ -0,0 +1,26 @@
import React from 'react';
const Sidebar = () => {
return (
<div className="sidebar">
<div className="logo">OPAX</div>
<div className="emma-status">Emma Local: Connected</div>
<nav>
<ul>
<li className="active">Chat</li>
<li>Work</li>
<li>Repository</li>
<li>Deployments</li>
<li>Costs</li>
</ul>
</nav>
<div className="ticket-context">
<div>Current selected ticket:</div>
<div>No active task</div>
</div>
<div className="profile">User Profile</div>
</div>
);
};
export default Sidebar;

View File

@ -1,12 +0,0 @@
import React from 'react';
const WorkQueueView = () => {
return (
<div className="console-card">
<h2>Work Queue</h2>
<p className="unavailable-message">No ticket read model configured yet.</p>
</div>
);
};
export default WorkQueueView;

View File

@ -1,138 +1,14 @@
import React, { useState, useEffect, useCallback } from 'react';
import PlatformStatus from '../components/PlatformStatus';
import ProjectRegistryView from '../components/ProjectRegistryView';
import WorkQueueView from '../components/WorkQueueView';
import ApprovalsView from '../components/ApprovalsView';
import AgentConsoleView from '../components/AgentConsoleView';
import CapabilityPanel from '../components/CapabilityPanel';
import React from 'react';
import Sidebar from '../components/Sidebar';
import ChatWorkspace from '../components/ChatWorkspace';
import ContextPanel from '../components/ContextPanel';
// --- Types for Safe Data Handling ---
type ConsoleStatus = 'success' | 'unavailable';
type SafeHealthSummary = {
status: 'ok';
service: string;
version: string;
};
type ConsoleStatusItem = {
status: ConsoleStatus;
summary: SafeHealthSummary | null;
message: 'Available' | 'Unavailable';
};
export interface ConsoleStatusResponse {
health: ConsoleStatusItem;
buildStatus: ConsoleStatusItem;
platformState: ConsoleStatusItem;
}
const unavailableItem: ConsoleStatusItem = {
status: 'unavailable',
summary: null,
message: 'Unavailable',
};
// --- Normalizer Function ---
type UnknownRecord = Record<string, unknown>;
function isRecord(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isHealthSummary(value: unknown): value is SafeHealthSummary {
return (
isRecord(value) &&
value.status === 'ok' &&
typeof value.service === 'string' &&
typeof value.version === 'string'
);
}
function normalizeStatusResponse(raw: unknown): ConsoleStatusResponse {
let health: ConsoleStatusItem = unavailableItem;
if (isRecord(raw) && isRecord(raw.health)) {
const healthItem = raw.health;
if (
healthItem.status === 'success' &&
isHealthSummary(healthItem.summary)
) {
health = {
status: 'success',
summary: {
status: 'ok',
service: healthItem.summary.service,
version: healthItem.summary.version,
},
message: 'Available',
};
}
}
return {
health,
buildStatus: unavailableItem,
platformState: unavailableItem,
};
}
// --- Component ---
const OperationsConsole = () => {
const [statusData, setStatusData] = useState<ConsoleStatusResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastRefreshed, setLastRefreshed] = useState<string>('');
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/console/status', { credentials: 'include' });
if (!res.ok) {
// Handle auth errors or server errors safely
if (res.status === 401) {
throw new Error('Access denied. Please log in again.');
} else {
throw new Error('Console data is unavailable.');
}
}
const rawData = await res.json();
setStatusData(normalizeStatusResponse(rawData));
setLastRefreshed(new Date().toLocaleTimeString());
} catch {
setError('Console data is unavailable. Please try again.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return (
<div className="console-page">
<div className="page-header">
<h1>OPAX Operations Console</h1>
<div className="header-actions">
<span>Last refreshed: {lastRefreshed || 'never'}</span>
<button onClick={fetchData} disabled={loading} className="primary">
{loading ? 'Refreshing...' : 'Refresh'}
</button>
</div>
</div>
{error && <p className="error">{error}</p>}
<PlatformStatus statusData={statusData} loading={loading} />
<ProjectRegistryView />
<WorkQueueView />
<ApprovalsView />
<AgentConsoleView />
<CapabilityPanel />
<div className="console-layout">
<Sidebar />
<ChatWorkspace />
<ContextPanel />
</div>
);
};