fix: remove dead delete+recreate and pin code, add poll fallback test

Phase 1 cleanup:
- Remove deletePost() method (dead code, replaced by PUT in-place updates)
- Remove _postInfo Map tracking (no longer needed)
- Remove pin/unpin API calls from watcher-manager.js (incompatible with PUT updates)
- Add JSDoc note on (edited) label limitation in _flushUpdate()
- Add integration test: test/integration/poll-fallback.test.js
- Fix addSession() lastOffset===0 falsy bug (0 was treated as 'no offset')
- Fix pre-existing test failures: add lastOffset:0 where tests expect backlog reads
- Fix pre-existing session-monitor test: create stub transcript files
- Fix pre-existing status-formatter test: update indent check for blockquote format
- Format plugin/ files with Prettier (pre-existing formatting drift)
This commit is contained in:
sol
2026-03-07 20:31:32 +00:00
parent cc485f0009
commit 868574d939
31 changed files with 3596 additions and 68 deletions

1934
plugin/webapp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
{
"name": "com.openclaw.livestatus-webapp",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "webpack --mode=production",
"dev": "webpack --mode=development --watch"
},
"devDependencies": {
"css-loader": "^6.11.0",
"style-loader": "^3.3.4",
"ts-loader": "^9.5.4",
"typescript": "^5.9.3",
"webpack": "^5.105.4",
"webpack-cli": "^5.1.4"
},
"dependencies": {
"@types/react": "^18.2.0"
}
}

View File

@@ -0,0 +1,180 @@
import React, { useState, useEffect } from 'react';
import TerminalView from './terminal_view';
import { LiveStatusData } from '../types';
interface LiveStatusPostProps {
post: {
id: string;
props: Record<string, any>;
};
theme: Record<string, string>;
}
// Global store for WebSocket updates (set by the plugin index)
declare global {
interface Window {
__livestatus_updates: Record<string, LiveStatusData>;
__livestatus_listeners: Record<string, Array<(data: LiveStatusData) => void>>;
}
}
if (typeof window !== 'undefined') {
window.__livestatus_updates = window.__livestatus_updates || {};
window.__livestatus_listeners = window.__livestatus_listeners || {};
}
/**
* Subscribe to live status updates for a given post ID.
*/
function useStatusUpdates(
postId: string,
initialData: LiveStatusData | null,
): LiveStatusData | null {
const [data, setData] = useState<LiveStatusData | null>(
window.__livestatus_updates[postId] || initialData,
);
useEffect(() => {
// Register listener
if (!window.__livestatus_listeners[postId]) {
window.__livestatus_listeners[postId] = [];
}
const listener = (newData: LiveStatusData) => setData(newData);
window.__livestatus_listeners[postId].push(listener);
// Check if we already have data
if (window.__livestatus_updates[postId]) {
setData(window.__livestatus_updates[postId]);
}
return () => {
const listeners = window.__livestatus_listeners[postId];
if (listeners) {
const idx = listeners.indexOf(listener);
if (idx >= 0) listeners.splice(idx, 1);
}
};
}, [postId]);
return data;
}
/**
* Format elapsed time in human-readable form.
*/
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`;
}
/**
* Format token count compactly.
*/
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);
}
/**
* LiveStatusPost — custom post type component.
* Renders a terminal-style live status view with auto-updating content.
*/
const LiveStatusPost: React.FC<LiveStatusPostProps> = ({ post, theme }) => {
const [elapsed, setElapsed] = useState(0);
// Build initial data from post props
const initialData: LiveStatusData = {
session_key: post.props.session_key || '',
post_id: post.id,
agent_id: post.props.agent_id || 'unknown',
status: post.props.status || 'active',
lines: post.props.final_lines || [],
elapsed_ms: post.props.elapsed_ms || 0,
token_count: post.props.token_count || 0,
children: [],
start_time_ms: post.props.start_time_ms || 0,
};
const data = useStatusUpdates(post.id, initialData);
const isActive = data?.status === 'active';
// Client-side elapsed time counter (ticks every 1s when 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]);
if (!data) {
return <div className="ls-post ls-loading">Loading status...</div>;
}
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-post ${statusClass}`}>
<div className="ls-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={30} />
{data.children && data.children.length > 0 && (
<div className="ls-children">
{data.children.map((child, i) => (
<ChildSession key={child.session_key || i} child={child} />
))}
</div>
)}
{!isActive && data.token_count > 0 && (
<div className="ls-footer">{formatTokens(data.token_count)} tokens</div>
)}
</div>
);
};
/**
* Renders a child/sub-agent session (collapsed by default).
*/
const ChildSession: React.FC<{ child: LiveStatusData }> = ({ child }) => {
const [expanded, setExpanded] = useState(false);
return (
<div className="ls-child">
<div
className="ls-child-header"
onClick={() => setExpanded(!expanded)}
style={{ cursor: 'pointer' }}
>
<span className="ls-expand-icon">{expanded ? '\u25BC' : '\u25B6'}</span>
<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>
{expanded && <TerminalView lines={child.lines} maxLines={15} />}
</div>
);
};
export default LiveStatusPost;

View File

@@ -0,0 +1,41 @@
import React from 'react';
interface StatusLineProps {
line: string;
}
/**
* Renders a single status line with formatting:
* - Tool calls: monospace tool name with colored result marker
* - Thinking text: dimmed with box-drawing prefix
*/
const StatusLine: React.FC<StatusLineProps> = ({ line }) => {
// Match tool call pattern: "toolName: arguments [OK]" or "toolName: arguments [ERR]"
const toolMatch = line.match(/^(\S+?): (.+?)( \[OK\]| \[ERR\])?$/);
if (toolMatch) {
const toolName = toolMatch[1];
const args = toolMatch[2];
const marker = (toolMatch[3] || '').trim();
return (
<div className="ls-status-line ls-tool-call">
<span className="ls-tool-name">{toolName}:</span>
<span className="ls-tool-args"> {args}</span>
{marker && (
<span className={marker === '[OK]' ? 'ls-marker-ok' : 'ls-marker-err'}> {marker}</span>
)}
</div>
);
}
// Thinking text
return (
<div className="ls-status-line ls-thinking">
<span className="ls-thinking-prefix">{'\u2502'} </span>
<span className="ls-thinking-text">{line}</span>
</div>
);
};
export default StatusLine;

View File

@@ -0,0 +1,46 @@
import React, { useRef, useEffect, useState } from 'react';
import StatusLine from './status_line';
interface TerminalViewProps {
lines: string[];
maxLines?: number;
}
/**
* Terminal-style scrolling container for status lines.
* Auto-scrolls to bottom on new content unless user has scrolled up.
*/
const TerminalView: React.FC<TerminalViewProps> = ({ lines, maxLines = 30 }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [userScrolled, setUserScrolled] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new lines arrive (unless user scrolled up)
useEffect(() => {
if (!userScrolled && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [lines.length, userScrolled]);
// Detect user scroll
const handleScroll = () => {
if (!containerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 20;
setUserScrolled(!isAtBottom);
};
// Show only the most recent lines
const visibleLines = lines.slice(-maxLines);
return (
<div className="ls-terminal" ref={containerRef} onScroll={handleScroll}>
{visibleLines.map((line, i) => (
<StatusLine key={i} line={line} />
))}
<div ref={bottomRef} />
</div>
);
};
export default TerminalView;

View File

@@ -0,0 +1,57 @@
import { PluginRegistry, WebSocketPayload, LiveStatusData } from './types';
import LiveStatusPost from './components/live_status_post';
import './styles/live_status.css';
const PLUGIN_ID = 'com.openclaw.livestatus';
const WS_EVENT = `custom_${PLUGIN_ID}_update`;
class LiveStatusPlugin {
private postTypeComponentId: string | null = null;
initialize(registry: PluginRegistry, store: any): void {
// Register custom post type renderer
this.postTypeComponentId = registry.registerPostTypeComponent(
'custom_livestatus',
LiveStatusPost,
);
// Register WebSocket event handler
registry.registerWebSocketEventHandler(WS_EVENT, (msg: any) => {
const data = msg.data as WebSocketPayload;
if (!data || !data.post_id) return;
// Update global store
const update: LiveStatusData = {
session_key: data.session_key,
post_id: data.post_id,
agent_id: data.agent_id,
status: data.status as LiveStatusData['status'],
lines: data.lines || [],
elapsed_ms: data.elapsed_ms || 0,
token_count: data.token_count || 0,
children: data.children || [],
start_time_ms: data.start_time_ms || 0,
};
window.__livestatus_updates[data.post_id] = update;
// Notify listeners
const listeners = window.__livestatus_listeners[data.post_id];
if (listeners) {
listeners.forEach((fn) => fn(update));
}
});
}
uninitialize(): void {
// Cleanup handled by Mattermost plugin framework
}
}
// Initialize global stores
if (typeof window !== 'undefined') {
window.__livestatus_updates = window.__livestatus_updates || {};
window.__livestatus_listeners = window.__livestatus_listeners || {};
}
(window as any).registerPlugin(PLUGIN_ID, new LiveStatusPlugin());

View File

@@ -0,0 +1,197 @@
/* OpenClaw Live Status — Post Type Styles */
.ls-post {
border-radius: 4px;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 4px 0;
}
/* Header */
.ls-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--center-channel-bg, #fff);
border-bottom: 1px solid var(--center-channel-color-08, rgba(0, 0, 0, 0.08));
}
.ls-agent-badge {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
background: var(--button-bg, #166de0);
color: var(--button-color, #fff);
}
.ls-child-badge {
font-size: 11px;
background: var(--center-channel-color-24, rgba(0, 0, 0, 0.24));
}
.ls-status-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 6px;
border-radius: 3px;
text-transform: uppercase;
}
.ls-status-active .ls-status-badge {
background: #e8f5e9;
color: #2e7d32;
}
.ls-status-done .ls-status-badge {
background: #e3f2fd;
color: #1565c0;
}
.ls-status-error .ls-status-badge {
background: #fbe9e7;
color: #c62828;
}
.ls-status-interrupted .ls-status-badge {
background: #fff3e0;
color: #e65100;
}
.ls-elapsed {
font-size: 12px;
color: var(--center-channel-color-56, rgba(0, 0, 0, 0.56));
margin-left: auto;
font-family: 'SFMono-Regular', Consolas, monospace;
}
/* Live dot — pulsing green indicator */
.ls-live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #4caf50;
animation: ls-pulse 1.5s ease-in-out infinite;
}
@keyframes ls-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.8);
}
}
/* Terminal view */
.ls-terminal {
max-height: 400px;
overflow-y: auto;
padding: 8px 12px;
background: var(--center-channel-color-04, rgba(0, 0, 0, 0.04));
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
line-height: 1.6;
}
/* Status lines */
.ls-status-line {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ls-tool-call {
color: var(--center-channel-color, #3d3c40);
}
.ls-tool-name {
color: var(--link-color, #2389d7);
font-weight: 600;
}
.ls-tool-args {
color: var(--center-channel-color-72, rgba(0, 0, 0, 0.72));
}
.ls-marker-ok {
color: #4caf50;
font-weight: 600;
}
.ls-marker-err {
color: #f44336;
font-weight: 600;
}
.ls-thinking {
color: var(--center-channel-color-48, rgba(0, 0, 0, 0.48));
}
.ls-thinking-prefix {
color: var(--center-channel-color-24, rgba(0, 0, 0, 0.24));
}
/* Children (sub-agents) */
.ls-children {
border-top: 1px solid var(--center-channel-color-08, rgba(0, 0, 0, 0.08));
padding: 4px 0;
}
.ls-child {
margin: 0 12px;
border-left: 2px solid var(--center-channel-color-16, rgba(0, 0, 0, 0.16));
padding-left: 8px;
}
.ls-child-header {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
font-size: 12px;
}
.ls-expand-icon {
font-size: 10px;
color: var(--center-channel-color-48, rgba(0, 0, 0, 0.48));
width: 12px;
}
/* Footer */
.ls-footer {
padding: 4px 12px 8px;
font-size: 11px;
color: var(--center-channel-color-48, rgba(0, 0, 0, 0.48));
font-family: 'SFMono-Regular', Consolas, monospace;
}
/* Loading state */
.ls-loading {
padding: 12px;
color: var(--center-channel-color-48, rgba(0, 0, 0, 0.48));
font-style: italic;
}
/* Scrollbar styling */
.ls-terminal::-webkit-scrollbar {
width: 6px;
}
.ls-terminal::-webkit-scrollbar-track {
background: transparent;
}
.ls-terminal::-webkit-scrollbar-thumb {
background: var(--center-channel-color-16, rgba(0, 0, 0, 0.16));
border-radius: 3px;
}
.ls-terminal::-webkit-scrollbar-thumb:hover {
background: var(--center-channel-color-32, rgba(0, 0, 0, 0.32));
}

View File

@@ -0,0 +1,39 @@
export interface LiveStatusData {
session_key: string;
post_id: string;
agent_id: string;
status: 'active' | 'done' | 'error' | 'interrupted';
lines: string[];
elapsed_ms: number;
token_count: number;
children: LiveStatusData[];
start_time_ms: number;
}
export interface WebSocketPayload {
post_id: string;
session_key: string;
agent_id: string;
status: string;
lines: string[];
elapsed_ms: number;
token_count: number;
children: LiveStatusData[];
start_time_ms: number;
}
// Mattermost plugin registry types (subset)
export interface PluginRegistry {
registerPostTypeComponent(typeName: string, component: any): string;
registerWebSocketEventHandler(event: string, handler: (msg: any) => void): void;
registerReducer(reducer: any): void;
unregisterComponent(componentId: string): void;
}
export interface Post {
id: string;
type: string;
props: Record<string, any>;
channel_id: string;
root_id: string;
}

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES6",
"module": "ESNext",
"moduleResolution": "node",
"jsx": "react",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"declaration": false,
"sourceMap": false,
"lib": ["ES6", "DOM"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,32 @@
var path = require('path');
module.exports = {
entry: './src/index.tsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'main.js',
library: {
type: 'umd',
},
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx'],
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
externals: {
react: 'React',
'react-dom': 'ReactDOM',
},
};