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:
sol
2026-03-08 19:55:44 +00:00
parent c36a048dbb
commit f0a51ce411
7 changed files with 363 additions and 4 deletions

File diff suppressed because one or more lines are too long

View 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;

View File

@@ -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));
}
});
}

View File

@@ -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));
}

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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)