Repository Public
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