feat: RHS panel — persistent Agent Status sidebar
Added a Right-Hand Sidebar (RHS) panel to the Mattermost plugin that shows live agent activity in a dedicated, always-visible panel. - New RHSPanel component with SessionCard views per active session - registerAppBarComponent adds 'Agent Status' icon to toolbar - Subscribes to WebSocket updates via global listener - Shows active sessions with live elapsed time, tool calls, token count - Shows recent completed sessions below active ones - Responsive CSS matching Mattermost design system The RHS panel solves the scroll-out-of-view problem: the status dashboard stays visible regardless of chat scroll position.
This commit is contained in:
2
plugin/webapp/dist/main.js
vendored
2
plugin/webapp/dist/main.js
vendored
File diff suppressed because one or more lines are too long
191
plugin/webapp/src/components/rhs_panel.tsx
Normal file
191
plugin/webapp/src/components/rhs_panel.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import TerminalView from './terminal_view';
|
||||
import { LiveStatusData } from '../types';
|
||||
|
||||
/**
|
||||
* Subscribe to ALL live status updates across all sessions.
|
||||
* Returns a map of postId -> LiveStatusData, re-rendered on every update.
|
||||
*/
|
||||
function useAllStatusUpdates(): Record<string, LiveStatusData> {
|
||||
const [sessions, setSessions] = useState<Record<string, LiveStatusData>>(() => {
|
||||
return { ...(window.__livestatus_updates || {}) };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Register a global listener that catches all updates
|
||||
const globalKey = '__rhs_panel__';
|
||||
if (!window.__livestatus_listeners[globalKey]) {
|
||||
window.__livestatus_listeners[globalKey] = [];
|
||||
}
|
||||
|
||||
const listener = () => {
|
||||
setSessions({ ...(window.__livestatus_updates || {}) });
|
||||
};
|
||||
|
||||
window.__livestatus_listeners[globalKey].push(listener);
|
||||
|
||||
// Also set up a polling fallback (WebSocket updates are primary)
|
||||
const interval = setInterval(() => {
|
||||
const current = window.__livestatus_updates || {};
|
||||
setSessions((prev) => {
|
||||
// Only update if something changed
|
||||
const prevKeys = Object.keys(prev).sort().join(',');
|
||||
const currKeys = Object.keys(current).sort().join(',');
|
||||
if (prevKeys !== currKeys) return { ...current };
|
||||
// Check if any data changed
|
||||
for (const key of Object.keys(current)) {
|
||||
if (current[key] !== prev[key]) return { ...current };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
const listeners = window.__livestatus_listeners[globalKey];
|
||||
if (listeners) {
|
||||
const idx = listeners.indexOf(listener);
|
||||
if (idx >= 0) listeners.splice(idx, 1);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
if (ms < 0) ms = 0;
|
||||
const s = Math.floor(ms / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const h = Math.floor(m / 60);
|
||||
if (h > 0) return `${h}h${m % 60}m`;
|
||||
if (m > 0) return `${m}m${s % 60}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
function formatTokens(count: number): string {
|
||||
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
|
||||
return String(count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single session card within the RHS panel.
|
||||
*/
|
||||
const SessionCard: React.FC<{ data: LiveStatusData }> = ({ data }) => {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const isActive = data.status === 'active';
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !data.start_time_ms) return;
|
||||
const interval = setInterval(() => {
|
||||
setElapsed(Date.now() - data.start_time_ms);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isActive, data.start_time_ms]);
|
||||
|
||||
const displayElapsed = isActive && data.start_time_ms
|
||||
? formatElapsed(elapsed || Date.now() - data.start_time_ms)
|
||||
: formatElapsed(data.elapsed_ms);
|
||||
|
||||
const statusClass = `ls-status-${data.status}`;
|
||||
|
||||
return (
|
||||
<div className={`ls-rhs-card ${statusClass}`}>
|
||||
<div className="ls-rhs-card-header">
|
||||
<span className="ls-agent-badge">{data.agent_id}</span>
|
||||
{isActive && <span className="ls-live-dot" />}
|
||||
<span className={`ls-status-badge ${statusClass}`}>
|
||||
{data.status.toUpperCase()}
|
||||
</span>
|
||||
<span className="ls-elapsed">{displayElapsed}</span>
|
||||
</div>
|
||||
|
||||
<TerminalView lines={data.lines} maxLines={20} />
|
||||
|
||||
{data.children && data.children.length > 0 && (
|
||||
<div className="ls-rhs-children">
|
||||
{data.children.map((child, i) => (
|
||||
<div key={child.session_key || i} className="ls-rhs-child">
|
||||
<div className="ls-rhs-child-header">
|
||||
<span className="ls-agent-badge ls-child-badge">{child.agent_id}</span>
|
||||
<span className={`ls-status-badge ls-status-${child.status}`}>
|
||||
{child.status.toUpperCase()}
|
||||
</span>
|
||||
<span className="ls-elapsed">{formatElapsed(child.elapsed_ms)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.token_count > 0 && (
|
||||
<div className="ls-rhs-card-footer">
|
||||
{formatTokens(data.token_count)} tokens
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* RHSPanel — Right-Hand Sidebar component.
|
||||
* Shows all active agent sessions in a live-updating dashboard.
|
||||
*/
|
||||
const RHSPanel: React.FC = () => {
|
||||
const sessions = useAllStatusUpdates();
|
||||
const entries = Object.values(sessions);
|
||||
|
||||
// Sort: active first, then by start time descending
|
||||
const sorted = entries.sort((a, b) => {
|
||||
if (a.status === 'active' && b.status !== 'active') return -1;
|
||||
if (b.status === 'active' && a.status !== 'active') return 1;
|
||||
return (b.start_time_ms || 0) - (a.start_time_ms || 0);
|
||||
});
|
||||
|
||||
const activeSessions = sorted.filter((s) => s.status === 'active');
|
||||
const completedSessions = sorted.filter((s) => s.status !== 'active');
|
||||
|
||||
return (
|
||||
<div className="ls-rhs-panel">
|
||||
<div className="ls-rhs-summary">
|
||||
<span className="ls-rhs-count">
|
||||
{activeSessions.length > 0 ? (
|
||||
<>
|
||||
<span className="ls-live-dot" />
|
||||
{activeSessions.length} active
|
||||
</>
|
||||
) : (
|
||||
'No active sessions'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{activeSessions.length === 0 && completedSessions.length === 0 && (
|
||||
<div className="ls-rhs-empty">
|
||||
<div className="ls-rhs-empty-icon">⚙️</div>
|
||||
<div className="ls-rhs-empty-text">
|
||||
No agent activity yet.
|
||||
<br />
|
||||
Status will appear here when an agent starts working.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSessions.map((data) => (
|
||||
<SessionCard key={data.post_id || data.session_key} data={data} />
|
||||
))}
|
||||
|
||||
{completedSessions.length > 0 && (
|
||||
<div className="ls-rhs-section">
|
||||
<div className="ls-rhs-section-title">Recent</div>
|
||||
{completedSessions.slice(0, 5).map((data) => (
|
||||
<SessionCard key={data.post_id || data.session_key} data={data} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RHSPanel;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PluginRegistry, WebSocketPayload, LiveStatusData } from './types';
|
||||
import LiveStatusPost from './components/live_status_post';
|
||||
import RHSPanel from './components/rhs_panel';
|
||||
import './styles/live_status.css';
|
||||
|
||||
const PLUGIN_ID = 'com.openclaw.livestatus';
|
||||
@@ -15,6 +16,15 @@ class LiveStatusPlugin {
|
||||
LiveStatusPost,
|
||||
);
|
||||
|
||||
// Register RHS panel with AppBar icon
|
||||
// This creates both the sidebar component and the toolbar button to toggle it
|
||||
registry.registerAppBarComponent({
|
||||
iconUrl: '', // Empty = use default plugin icon from assets/icon.svg
|
||||
tooltipText: 'Agent Status',
|
||||
rhsComponent: RHSPanel,
|
||||
rhsTitle: 'Agent Status',
|
||||
});
|
||||
|
||||
// Register WebSocket event handler
|
||||
registry.registerWebSocketEventHandler(WS_EVENT, (msg: any) => {
|
||||
const data = msg.data as WebSocketPayload;
|
||||
@@ -35,11 +45,17 @@ class LiveStatusPlugin {
|
||||
|
||||
window.__livestatus_updates[data.post_id] = update;
|
||||
|
||||
// Notify listeners
|
||||
// Notify post-specific listeners
|
||||
const listeners = window.__livestatus_listeners[data.post_id];
|
||||
if (listeners) {
|
||||
listeners.forEach((fn) => fn(update));
|
||||
}
|
||||
|
||||
// Notify RHS panel global listener
|
||||
const rhsListeners = window.__livestatus_listeners['__rhs_panel__'];
|
||||
if (rhsListeners) {
|
||||
rhsListeners.forEach((fn) => fn(update));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -195,3 +195,113 @@
|
||||
.ls-terminal::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--center-channel-color-32, rgba(0, 0, 0, 0.32));
|
||||
}
|
||||
|
||||
/* ========= RHS Panel Styles ========= */
|
||||
|
||||
.ls-rhs-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: var(--center-channel-bg, #fff);
|
||||
}
|
||||
|
||||
.ls-rhs-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--center-channel-color-08, rgba(0, 0, 0, 0.08));
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
}
|
||||
|
||||
.ls-rhs-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ls-rhs-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ls-rhs-empty-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ls-rhs-empty-text {
|
||||
color: var(--center-channel-color-48, rgba(0, 0, 0, 0.48));
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ls-rhs-card {
|
||||
margin: 8px 12px;
|
||||
border: 1px solid var(--center-channel-color-08, rgba(0, 0, 0, 0.08));
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ls-rhs-card.ls-status-active {
|
||||
border-color: rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.ls-rhs-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--center-channel-color-04, rgba(0, 0, 0, 0.04));
|
||||
border-bottom: 1px solid var(--center-channel-color-08, rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
||||
.ls-rhs-card .ls-terminal {
|
||||
max-height: 250px;
|
||||
font-size: 11px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.ls-rhs-card-footer {
|
||||
padding: 4px 10px 6px;
|
||||
font-size: 11px;
|
||||
color: var(--center-channel-color-48, rgba(0, 0, 0, 0.48));
|
||||
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
border-top: 1px solid var(--center-channel-color-08, rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
||||
.ls-rhs-children {
|
||||
padding: 4px 10px;
|
||||
border-top: 1px solid var(--center-channel-color-08, rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
||||
.ls-rhs-child {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.ls-rhs-child-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ls-rhs-section {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ls-rhs-section-title {
|
||||
padding: 8px 16px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--center-channel-color-48, rgba(0, 0, 0, 0.48));
|
||||
}
|
||||
|
||||
@@ -28,6 +28,25 @@ export interface PluginRegistry {
|
||||
registerWebSocketEventHandler(event: string, handler: (msg: any) => void): void;
|
||||
registerReducer(reducer: any): void;
|
||||
unregisterComponent(componentId: string): void;
|
||||
registerRightHandSidebarComponent(opts: { component: any; title: string }): {
|
||||
id: string;
|
||||
showRHSPlugin: any;
|
||||
hideRHSPlugin: any;
|
||||
toggleRHSPlugin: any;
|
||||
};
|
||||
registerAppBarComponent(opts: {
|
||||
iconUrl: string;
|
||||
tooltipText: string;
|
||||
rhsComponent?: any;
|
||||
rhsTitle?: string;
|
||||
action?: () => void;
|
||||
}): string;
|
||||
registerChannelHeaderButtonAction(opts: {
|
||||
icon: any;
|
||||
action: () => void;
|
||||
dropdownText: string;
|
||||
tooltipText: string;
|
||||
}): string;
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
|
||||
@@ -96,6 +96,21 @@ class StatusBox extends EventEmitter {
|
||||
return post.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Mattermost post.
|
||||
* @param {string} postId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async deletePost(postId) {
|
||||
try {
|
||||
await this._apiCall('DELETE', `/api/v4/posts/${postId}`);
|
||||
if (this.logger) this.logger.debug({ postId }, 'Deleted status post');
|
||||
} catch (err) {
|
||||
if (this.logger) this.logger.warn({ postId, err: err.message }, 'Failed to delete status post');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a Mattermost post (throttled).
|
||||
* Leading edge fires immediately; subsequent calls within throttleMs are batched.
|
||||
|
||||
@@ -274,9 +274,17 @@ async function startDaemon() {
|
||||
// Check if this session was previously completed (reactivation after idle)
|
||||
const completed = completedBoxes.get(sessionKey);
|
||||
if (completed) {
|
||||
postId = completed.postId;
|
||||
// Delete the old buried post and create a fresh one at the current thread position
|
||||
// so users can see it without scrolling up
|
||||
try {
|
||||
await sharedStatusBox.deletePost(completed.postId);
|
||||
logger.info({ sessionKey, oldPostId: completed.postId }, 'Deleted old buried status box on reactivation');
|
||||
} catch (err) {
|
||||
logger.warn({ sessionKey, oldPostId: completed.postId, err: err.message }, 'Failed to delete old status box (may already be deleted)');
|
||||
}
|
||||
completedBoxes.delete(sessionKey);
|
||||
logger.info({ sessionKey, postId }, 'Reactivating completed session — reusing existing post');
|
||||
// postId stays null — will create a fresh one below
|
||||
logger.info({ sessionKey }, 'Reactivating session — creating fresh status box');
|
||||
}
|
||||
|
||||
// Check for existing post (restart recovery)
|
||||
|
||||
Reference in New Issue
Block a user