/** Accessible, poll-backed transcript for externally started Hermes workers. */ import { Button } from "@nous-research/ui/ui/components/button"; import { Activity, ChevronDown, ChevronUp, RefreshCw, TerminalSquare, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { api, type SessionInfo, type SessionMessage, } from "@/lib/api"; const POLL_MS = 2_000; const DEFAULT_VISIBLE_MESSAGES = 250; interface SessionActivityPanelProps { sessionId: string; profile?: string; } function messageLabel(message: SessionMessage): string { if (message.role === "tool") { return message.tool_name ? `Tool result: ${message.tool_name}` : "Tool result"; } if (message.tool_calls?.length) { const names = message.tool_calls .map((call) => call?.function?.name) .filter(Boolean) .join(", "); return names ? `Tool calls: ${names}` : "Tool calls"; } return message.role === "assistant" ? "Hermes" : message.role; } function displayText(value: unknown): string { if (typeof value === "string") return value; if (value == null) return ""; try { return JSON.stringify(value, null, 2); } catch { return String(value); } } function ExpandablePayload({ payload, summary, openByDefault = false, }: { payload: string; summary: string; openByDefault?: boolean; }) { const [open, setOpen] = useState(openByDefault); return (
setOpen(event.currentTarget.open)} > {summary} {open && (
          {payload}
        
)}
); } function latestTimestamp(messages: SessionMessage[]): number | null { let latest: number | null = null; for (const message of messages) { if (typeof message.timestamp !== "number") continue; latest = latest === null ? message.timestamp : Math.max(latest, message.timestamp); } return latest; } function ActivityMessage({ message, index, }: { message: SessionMessage; index: number; }) { const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; const content = displayText(message.content); const isToolResult = message.role === "tool"; return (
{messageLabel(message)} {typeof message.timestamp === "number" && ( )}
{toolCalls.map((call, callIndex) => { const name = call?.function?.name || "tool"; return ( ); })} {content && isToolResult ? ( ) : content ? (
          {content}
        
) : toolCalls.length === 0 ? (
Waiting for the next recorded event…
) : null}
); } /** * Show every persisted message and tool event without relying on xterm's * canvas. Resumed sessions default to this view so an unknown or delayed * backend source classification cannot hide activity; the normal terminal * remains one click away and continues running underneath it. */ export function SessionActivityPanel({ sessionId, profile, }: SessionActivityPanelProps) { const [detail, setDetail] = useState(null); const [messages, setMessages] = useState([]); const [totalMessages, setTotalMessages] = useState(0); const [expanded, setExpanded] = useState(true); const [visibleLimit, setVisibleLimit] = useState(DEFAULT_VISIBLE_MESSAGES); const [error, setError] = useState(null); const [pollTime, setPollTime] = useState(Date.now()); const requestRunning = useRef(false); const scrollRef = useRef(null); const stickToBottom = useRef(true); useEffect(() => { // index.html exposes an immediate, screen-reader-visible loading state for // resumed links. Remove it only after this durable transcript has mounted. document.getElementById("hermes-resume-bootstrap")?.remove(); }, []); const load = useCallback(async () => { if (requestRunning.current) return; requestRunning.current = true; try { const [nextDetail, response] = await Promise.all([ api.getSessionDetail(sessionId, profile ?? ""), api.getSessionMessages(sessionId, profile ?? "", visibleLimit), ]); setDetail(nextDetail); const nextMessages = Array.isArray(response.messages) ? response.messages : []; setMessages(nextMessages); setTotalMessages( typeof response.total_messages === "number" ? response.total_messages : nextMessages.length, ); setError(null); setPollTime(Date.now()); if (stickToBottom.current) { requestAnimationFrame(() => { const node = scrollRef.current; if (node) node.scrollTop = node.scrollHeight; }); } } catch (cause) { setError(cause instanceof Error ? cause.message : "activity feed unavailable"); } finally { requestRunning.current = false; } }, [profile, sessionId, visibleLimit]); useEffect(() => { setVisibleLimit(DEFAULT_VISIBLE_MESSAGES); }, [profile, sessionId]); useEffect(() => { void load(); const timer = window.setInterval(() => void load(), POLL_MS); const onVisibility = () => { if (!document.hidden) void load(); }; document.addEventListener("visibilitychange", onVisibility); return () => { window.clearInterval(timer); document.removeEventListener("visibilitychange", onVisibility); }; }, [load]); const latest = useMemo(() => latestTimestamp(messages), [messages]); const visibleMessages = useMemo( () => messages.length <= visibleLimit ? messages : messages.slice(-visibleLimit), [messages, visibleLimit], ); const hiddenMessageCount = Math.max(0, totalMessages - visibleMessages.length); const quietSeconds = latest === null ? null : Math.max(0, pollTime / 1000 - latest); const running = detail?.ended_at == null; const status = error ? "Activity feed reconnecting" : running && quietSeconds !== null && quietSeconds > 15 ? "Working — waiting for the model or a tool result" : running ? "Working" : "Run finished"; if (!expanded) { return ( ); } return (
Live worker activity
{status} · {messages.length.toLocaleString()} of{" "} {totalMessages.toLocaleString()} recorded events loaded
{error && (
{error}. Retrying automatically.
)}
{ const node = event.currentTarget; stickToBottom.current = node.scrollHeight - node.scrollTop - node.clientHeight < 80; }} > {hiddenMessageCount > 0 && (
)} {messages.length === 0 ? (
Connecting to the worker transcript…
This view refreshes every two seconds.
) : ( visibleMessages.map((message, index) => ( )) )}
Poll-backed transcript; terminal rendering is not required. {expanded ? : } Updated {new Date(pollTime).toLocaleTimeString()}
); }