SPB Git

Repository Public

task-log.tsx 5.5 KB 129 lines tsx

1'use client';2/**3 * Live task log. Rendered by a small read-only xterm instance, so ANSI colours are honoured4 * and long logs stay cheap (xterm virtualizes rows; scrollback is bounded). History is loaded5 * incrementally by offset over HTTP, then the WebSocket stream continues from that offset.6 */7import { useEffect, useRef, useState } from 'react';8import { Terminal } from '@xterm/xterm';9import { FitAddon } from '@xterm/addon-fit';10import '@xterm/xterm/css/xterm.css';11import { ArrowDownToLine } from 'lucide-react';12import { api, wsUrl } from '@/lib/api';13import { fmtBytes } from '@/lib/format';14import { TERM_THEMES, termTheme } from '@/components/terminal/controller';15import { useTerminalTheme } from '@/hooks/use-terminal-theme';16import { cn } from '@/lib/cn';1718export default function TaskLog({ taskId, active }: { taskId: string; active: boolean }) {19  const host = useRef<HTMLDivElement>(null);20  const termRef = useRef<Terminal | null>(null);21  const followRef = useRef(true);22  const [follow, setFollow] = useState(true);23  const [bytes, setBytes] = useState(0);24  const [state, setState] = useState<'loading' | 'streaming' | 'complete' | 'error'>('loading');25  const theme = useTerminalTheme();26  const themeRef = useRef(theme);27  themeRef.current = theme;28  useEffect(() => {29    if (termRef.current) termRef.current.options.theme = { ...termTheme(theme), cursor: 'transparent' };30  }, [theme]);31  const activeRef = useRef(active);32  activeRef.current = active;3334  useEffect(() => {35    const el = host.current;36    if (!el) return;37    const term = new Terminal({ disableStdin: true, convertEol: true, cursorStyle: 'bar', cursorInactiveStyle: 'none', fontFamily: '"JetBrains Mono", ui-monospace, Menlo, monospace', fontSize: 12, lineHeight: 1.2, scrollback: 50_000, theme: { ...termTheme(themeRef.current), cursor: 'transparent' } });38    const fit = new FitAddon();39    term.loadAddon(fit);40    term.open(el);41    termRef.current = term;42    const ro = new ResizeObserver(() => { try { fit.fit(); } catch { /* hidden */ } });43    ro.observe(el);44    term.onScroll(() => {45      const b = term.buffer.active;46      const atBottom = b.viewportY >= b.baseY;47      if (followRef.current !== atBottom) {48        followRef.current = atBottom;49        setFollow(atBottom);50      }51    });52    if (process.env.NEXT_PUBLIC_E2E === '1') (window as unknown as { __crTaskLog: () => string }).__crTaskLog = () => {53      const b = term.buffer.active;54      const out: string[] = [];55      for (let i = 0; i < b.length; i++) out.push(b.getLine(i)?.translateToString(true) ?? '');56      return out.join('\n');57    };5859    let cancelled = false;60    let ws: WebSocket | null = null;61    let offset = 0;62    let retry: ReturnType<typeof setTimeout> | null = null;63    const write = (chunk: Uint8Array) => {64      offset += chunk.byteLength;65      setBytes(offset);66      term.write(chunk, () => followRef.current && term.scrollToBottom());67    };68    const history = async () => {69      for (;;) {70        const res = await api.raw(`/tasks/${taskId}/log?offset=${offset}`);71        const size = Number(res.headers.get('X-Log-Size') ?? NaN);72        const buf = new Uint8Array(await res.arrayBuffer());73        if (cancelled) return;74        if (buf.byteLength) write(buf);75        if (buf.byteLength === 0 || !Number.isFinite(size) || offset >= size) return;76      }77    };78    const stream = () => {79      if (cancelled) return;80      setState('streaming');81      ws = new WebSocket(wsUrl(`/tasks/${taskId}/log/stream?offset=${offset}`));82      ws.binaryType = 'arraybuffer';83      ws.onmessage = (e) => typeof e.data !== 'string' && write(new Uint8Array(e.data as ArrayBuffer));84      ws.onclose = (e) => {85        if (cancelled) return;86        // Normal close = task ended. Otherwise catch up over HTTP and re-follow while the task is active.87        void history().catch(() => undefined).then(() => {88          if (cancelled) return;89          if (e.code === 1000 || !activeRef.current) setState('complete');90          else retry = setTimeout(stream, 1500);91        });92      };93    };94    history()95      .then(() => (cancelled ? undefined : activeRef.current ? stream() : setState('complete')))96      .catch(() => !cancelled && setState('error'));97    return () => {98      cancelled = true;99      if (retry) clearTimeout(retry);100      ws?.close();101      ro.disconnect();102      term.dispose();103      termRef.current = null;104    };105  }, [taskId]);106107  return (108    <div className="flex h-full min-h-0 flex-col">109      <div className="flex items-center gap-3 border-b border-line px-3 py-1.5 text-[11px] text-muted">110        <span className="tnum" data-testid="log-state">{state === 'loading' ? 'Loading log…' : state === 'streaming' ? 'Live' : state === 'error' ? 'Log unavailable' : 'Complete'} · {fmtBytes(bytes)}</span>111        <span className="flex-1" />112        <button113          aria-pressed={follow}114          onClick={() => {115            const next = !follow;116            followRef.current = next;117            setFollow(next);118            if (next) termRef.current?.scrollToBottom();119          }}120          className={cn('flex h-6 items-center gap-1.5 rounded-sm border px-2 pointer-coarse:h-9', follow ? 'border-accent/50 bg-accent/10 text-accent' : 'border-line-2 hover:text-text-2')}121        >122          <ArrowDownToLine className="size-3" /> Follow tail123        </button>124      </div>125      <div ref={host} style={{ background: TERM_THEMES[theme].background }} className="m-2 min-h-0 flex-1 overflow-hidden rounded-md border border-card-line px-2.5 pt-2 shadow-card" data-testid="task-log" data-term-theme={theme} />126    </div>127  );128}129
Files main
Menu

Theme