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 { PluginRegistry, WebSocketPayload, LiveStatusData } from './types';
|
||||||
import LiveStatusPost from './components/live_status_post';
|
import LiveStatusPost from './components/live_status_post';
|
||||||
|
import RHSPanel from './components/rhs_panel';
|
||||||
import './styles/live_status.css';
|
import './styles/live_status.css';
|
||||||
|
|
||||||
const PLUGIN_ID = 'com.openclaw.livestatus';
|
const PLUGIN_ID = 'com.openclaw.livestatus';
|
||||||
@@ -15,6 +16,15 @@ class LiveStatusPlugin {
|
|||||||
LiveStatusPost,
|
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
|
// Register WebSocket event handler
|
||||||
registry.registerWebSocketEventHandler(WS_EVENT, (msg: any) => {
|
registry.registerWebSocketEventHandler(WS_EVENT, (msg: any) => {
|
||||||
const data = msg.data as WebSocketPayload;
|
const data = msg.data as WebSocketPayload;
|
||||||
@@ -35,11 +45,17 @@ class LiveStatusPlugin {
|
|||||||
|
|
||||||
window.__livestatus_updates[data.post_id] = update;
|
window.__livestatus_updates[data.post_id] = update;
|
||||||
|
|
||||||
// Notify listeners
|
// Notify post-specific listeners
|
||||||
const listeners = window.__livestatus_listeners[data.post_id];
|
const listeners = window.__livestatus_listeners[data.post_id];
|
||||||
if (listeners) {
|
if (listeners) {
|
||||||
listeners.forEach((fn) => fn(update));
|
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 {
|
.ls-terminal::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--center-channel-color-32, rgba(0, 0, 0, 0.32));
|
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;
|
registerWebSocketEventHandler(event: string, handler: (msg: any) => void): void;
|
||||||
registerReducer(reducer: any): void;
|
registerReducer(reducer: any): void;
|
||||||
unregisterComponent(componentId: string): 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 {
|
export interface Post {
|
||||||
|
|||||||
@@ -96,6 +96,21 @@ class StatusBox extends EventEmitter {
|
|||||||
return post.id;
|
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).
|
* Update a Mattermost post (throttled).
|
||||||
* Leading edge fires immediately; subsequent calls within throttleMs are batched.
|
* 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)
|
// Check if this session was previously completed (reactivation after idle)
|
||||||
const completed = completedBoxes.get(sessionKey);
|
const completed = completedBoxes.get(sessionKey);
|
||||||
if (completed) {
|
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);
|
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)
|
// Check for existing post (restart recovery)
|
||||||
|
|||||||
Reference in New Issue
Block a user