Intelligence
Artifacts
Browse the repository, read documents, and manage the governance folders. Source, runtime, and infrastructure are read-only.
Repository
assignment-dispatch-button.tsxassignment-status-badge.tsxassignments-panel.tsxcreate-mission-form.tsxgovernance-panels.tsxmission-dispatch.tsxmission-edit.tsxmission-state-actions.tsxmission-status-badge.tsxobjective-edit.tsxobjective-status-badge.tsxobjective-status-select.tsxobjectives-panel.tsxreport-list.tsxwork-orders-panel.tsx
README.md
CONSTITUTION_COMPLIANCE_AUDIT_V1.mdREADME.md
repositories/aaf-holdings/hq01/components/sessions/log-viewer.tsx
3.5 KB
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
import type { SessionLogs } from "@/lib/sessions/types";
/**
* Read-only log viewer. Polls the session's log endpoint every few seconds (no
* websocket) and renders the tail of stdout or stderr in a monospace pane.
* Auto-scrolls to the newest output unless the operator has scrolled up.
*/
export function LogViewer({
id,
intervalMs = 3000,
}: {
id: string;
intervalMs?: number;
}) {
const [stream, setStream] = useState<"stdout" | "stderr">("stdout");
const [logs, setLogs] = useState<SessionLogs | null>(null);
const [error, setError] = useState<string | null>(null);
const [autoScroll, setAutoScroll] = useState(true);
const preRef = useRef<HTMLPreElement>(null);
const load = useCallback(async () => {
try {
const res = await fetch(`/api/sessions/${id}/logs`, { cache: "no-store" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? `HTTP ${res.status}`);
}
setLogs((await res.json()) as SessionLogs);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load logs.");
}
}, [id]);
useEffect(() => {
load();
const t = setInterval(load, intervalMs);
return () => clearInterval(t);
}, [load, intervalMs]);
const text = (stream === "stdout" ? logs?.stdout : logs?.stderr) ?? "";
useEffect(() => {
if (autoScroll && preRef.current) {
preRef.current.scrollTop = preRef.current.scrollHeight;
}
}, [text, autoScroll]);
function onScroll() {
const el = preRef.current;
if (!el) return;
const atBottom =
el.scrollHeight - el.scrollTop - el.clientHeight < 40;
setAutoScroll(atBottom);
}
return (
<div className="overflow-hidden rounded-lg border border-border bg-[#0d1117]">
<div className="flex items-center justify-between gap-2 border-b border-white/10 px-3 py-2">
<div className="flex items-center gap-1">
<StreamTab
active={stream === "stdout"}
onClick={() => setStream("stdout")}
>
stdout
</StreamTab>
<StreamTab
active={stream === "stderr"}
onClick={() => setStream("stderr")}
>
stderr
</StreamTab>
</div>
<div className="flex items-center gap-2 text-[11px] text-white/50">
<RefreshCw className="h-3 w-3" />
<span>auto-refresh {Math.round(intervalMs / 1000)}s</span>
</div>
</div>
<pre
ref={preRef}
onScroll={onScroll}
className="m-0 max-h-[28rem] min-h-[16rem] overflow-auto whitespace-pre-wrap break-words px-4 py-3 font-mono text-[12px] leading-relaxed text-[#c9d1d9]"
>
{error ? (
<span className="text-red-400">{error}</span>
) : text ? (
text
) : (
<span className="text-white/30">No output yet.</span>
)}
</pre>
</div>
);
}
function StreamTab({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"rounded px-2.5 py-1 font-mono text-[11px] font-medium transition-colors",
active
? "bg-white/10 text-white"
: "text-white/40 hover:text-white/70",
)}
>
{children}
</button>
);
}
root · /srv/aaf