atlas-iac/dockerfiles/hermes-session-activity-panel.tsx

362 lines
12 KiB
TypeScript
Raw Normal View History

/** 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 (
<details
className="my-1 rounded border border-white/10 px-2 py-1"
open={open}
onToggle={(event) => setOpen(event.currentTarget.open)}
>
<summary className="cursor-pointer select-none text-white/75">{summary}</summary>
{open && (
<pre className="mt-2 max-h-96 overflow-auto whitespace-pre-wrap break-words text-[0.6875rem] leading-relaxed text-white/75">
{payload}
</pre>
)}
</details>
);
}
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 (
<article
className="rounded border border-white/15 bg-white/5 px-3 py-2 text-xs text-white/85"
data-message-index={index}
>
<div className="mb-1 flex flex-wrap items-center justify-between gap-2 text-[0.6875rem] font-semibold uppercase tracking-wider text-white/60">
<span>{messageLabel(message)}</span>
{typeof message.timestamp === "number" && (
<time dateTime={new Date(message.timestamp * 1000).toISOString()}>
{new Date(message.timestamp * 1000).toLocaleTimeString()}
</time>
)}
</div>
{toolCalls.map((call, callIndex) => {
const name = call?.function?.name || "tool";
return (
<ExpandablePayload
key={call?.id || `${index}-${callIndex}`}
payload={displayText(call?.function?.arguments)}
summary={`${name} arguments`}
/>
);
})}
{content && isToolResult ? (
<ExpandablePayload
payload={content}
summary={`${content.length.toLocaleString()} characters of output`}
openByDefault={content.length < 1_500}
/>
) : content ? (
<pre className="whitespace-pre-wrap break-words font-sans leading-relaxed text-white/90">
{content}
</pre>
) : toolCalls.length === 0 ? (
<div className="text-white/50">Waiting for the next recorded event</div>
) : null}
</article>
);
}
/**
* 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<SessionInfo | null>(null);
const [messages, setMessages] = useState<SessionMessage[]>([]);
const [totalMessages, setTotalMessages] = useState(0);
const [expanded, setExpanded] = useState(true);
const [visibleLimit, setVisibleLimit] = useState(DEFAULT_VISIBLE_MESSAGES);
const [error, setError] = useState<string | null>(null);
const [pollTime, setPollTime] = useState(Date.now());
const requestRunning = useRef(false);
const scrollRef = useRef<HTMLDivElement | null>(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 (
<Button
onClick={() => setExpanded(true)}
prefix={<Activity className="h-4 w-4" />}
aria-expanded="false"
className="absolute left-4 top-4 z-30 bg-black/90 text-white"
>
Show live activity
</Button>
);
}
return (
<section
className="absolute inset-0 z-30 flex min-h-0 flex-col bg-black text-white"
aria-label={`Live activity for ${detail?.title || sessionId}`}
>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-white/15 px-3 py-2">
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-semibold">
<Activity className="h-4 w-4 text-warning" aria-hidden />
<span className="truncate">Live worker activity</span>
</div>
<div
className="mt-0.5 text-[0.6875rem] text-white/65"
aria-live="polite"
aria-atomic="true"
>
{status} · {messages.length.toLocaleString()} of{" "}
{totalMessages.toLocaleString()} recorded events loaded
</div>
</div>
<div className="flex items-center gap-2">
<Button
ghost
onClick={() => void load()}
prefix={<RefreshCw className="h-3.5 w-3.5" />}
aria-label="Refresh worker activity"
className="text-white"
>
Refresh
</Button>
<Button
ghost
onClick={() => setExpanded(false)}
prefix={<TerminalSquare className="h-3.5 w-3.5" />}
aria-expanded="true"
className="text-white"
>
Terminal view
</Button>
</div>
</div>
{error && (
<div className="border-b border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{error}. Retrying automatically.
</div>
)}
<div
ref={scrollRef}
className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3"
role="log"
aria-live="polite"
aria-relevant="additions text"
aria-busy={running && messages.length === 0}
aria-label="Chronological Hermes worker activity"
tabIndex={0}
onScroll={(event) => {
const node = event.currentTarget;
stickToBottom.current =
node.scrollHeight - node.scrollTop - node.clientHeight < 80;
}}
>
{hiddenMessageCount > 0 && (
<div className="sticky top-0 z-10 flex justify-center pb-2">
<Button
onClick={() => {
stickToBottom.current = false;
setVisibleLimit((current) => current + DEFAULT_VISIBLE_MESSAGES);
}}
prefix={<ChevronUp className="h-3.5 w-3.5" />}
aria-label={`Show up to ${Math.min(
DEFAULT_VISIBLE_MESSAGES,
hiddenMessageCount,
)} earlier recorded events`}
className="bg-black/95 text-white"
>
Show up to{" "}
{Math.min(
DEFAULT_VISIBLE_MESSAGES,
hiddenMessageCount,
).toLocaleString()}{" "}
earlier events
</Button>
</div>
)}
{messages.length === 0 ? (
<div className="flex min-h-full flex-col items-center justify-center gap-3 text-center text-sm text-white/65">
<Activity className="h-7 w-7 animate-pulse text-warning" aria-hidden />
<div>
<div className="font-medium text-white/85">Connecting to the worker transcript</div>
<div className="mt-1 text-xs">This view refreshes every two seconds.</div>
</div>
</div>
) : (
visibleMessages.map((message, index) => (
<ActivityMessage
key={`${message.timestamp ?? "untimed"}-${hiddenMessageCount + index}`}
message={message}
index={hiddenMessageCount + index}
/>
))
)}
</div>
<div className="flex items-center justify-between border-t border-white/15 px-3 py-1.5 text-[0.625rem] text-white/50">
<span>Poll-backed transcript; terminal rendering is not required.</span>
<span className="flex items-center gap-1">
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
Updated {new Date(pollTime).toLocaleTimeString()}
</span>
</div>
</section>
);
}