362 lines
12 KiB
TypeScript
362 lines
12 KiB
TypeScript
/** 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>
|
|
);
|
|
}
|