Intelligence

Artifacts

Browse the repository, read documents, and manage the governance folders. Source, runtime, and infrastructure are read-only.

Repository
.gitignoreDockerfilenext-env.d.tsnext.config.mjspackage-lock.jsonpackage.jsonpostcss.config.mjsREADME.mdtailwind.config.tstsconfig.jsontsconfig.tsbuildinfo
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