Files
MATTERMOST_OPENCLAW_LIVESTATUS/plugin/webapp/src/components/live_status_post.tsx
sol 868574d939 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)
2026-03-07 20:31:32 +00:00

181 lines
5.3 KiB
TypeScript

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;