web/app/components/log-viewer.tsxblame
View source
fe3b5091"use client";
fe3b5092
fe3b5093import { useEffect, useMemo, useRef, useState, useCallback } from "react";
fe3b5094import type { StepLog } from "@/lib/api";
fe3b5095
fe3b5096// --- Parsing types ---
fe3b5097
fe3b5098type LogLevel = "error" | "warn" | "info" | "debug" | "plain";
fe3b5099
fe3b50910interface ParsedLine {
fe3b50911 text: string;
fe3b50912 displayText: string;
fe3b50913 level: LogLevel;
fe3b50914}
fe3b50915
fe3b50916interface DockerGroup {
fe3b50917 stepNumber: number;
fe3b50918 headerText: string;
fe3b50919 duration: string | null;
fe3b50920 lines: ParsedLine[];
fe3b50921 isDone: boolean;
fe3b50922 hasError: boolean;
fe3b50923 hasWarning: boolean;
fe3b50924}
fe3b50925
fe3b50926type LogSegment =
fe3b50927 | { type: "docker-group"; group: DockerGroup }
fe3b50928 | { type: "plain"; lines: ParsedLine[] };
fe3b50929
fe3b50930// --- Regexes ---
fe3b50931
fe3b50932const DOCKER_STEP_RE = /^#(\d+)\s/;
fe3b50933const DOCKER_STEP_HEADER_RE = /^#\d+\s+(?:\[.*?\]|FROM\s|CACHED)/;
fe3b50934const DOCKER_DONE_RE = /^#\d+\s+DONE\s+([\d.]+s)/;
fe3b50935
fe3b50936const ERROR_RE =
fe3b50937 /\b(ERROR|FAILED|FATAL|PANIC|error:|Error:|cannot find|no such file|permission denied|exit code [1-9]|segmentation fault)\b/i;
fe3b50938const WARN_RE =
fe3b50939 /\b(WARN|WARNING|warn:|warning:|deprecated|DEPRECATED)\b/i;
fe3b50940const INFO_RE =
fe3b50941 /\b(INFO|info:|notice:|NOTE:)\b/i;
fe3b50942const DEBUG_RE =
fe3b50943 /\b(DEBUG|debug:|TRACE|trace:)\b/i;
fe3b50944
fe3b50945function classifyLine(text: string): LogLevel {
fe3b50946 if (ERROR_RE.test(text)) return "error";
fe3b50947 if (WARN_RE.test(text)) return "warn";
fe3b50948 if (INFO_RE.test(text)) return "info";
fe3b50949 if (DEBUG_RE.test(text)) return "debug";
fe3b50950 return "plain";
fe3b50951}
fe3b50952
fe3b50953// --- Level colors ---
fe3b50954
fe3b50955const levelColors: Record<LogLevel, { color: string; bg?: string; border: string }> = {
fe3b50956 error: {
fe3b50957 color: "var(--status-closed-text)",
fe3b50958 bg: "var(--status-closed-bg)",
fe3b50959 border: "var(--status-closed-border)",
fe3b50960 },
fe3b50961 warn: {
fe3b50962 color: "var(--status-merged-text)",
fe3b50963 border: "var(--status-merged-border)",
fe3b50964 },
fe3b50965 info: {
fe3b50966 color: "var(--status-open-text)",
fe3b50967 border: "var(--status-open-border)",
fe3b50968 },
fe3b50969 debug: {
fe3b50970 color: "var(--text-faint)",
fe3b50971 border: "var(--text-faint)",
fe3b50972 },
fe3b50973 plain: {
fe3b50974 color: "var(--text-primary)",
fe3b50975 border: "transparent",
fe3b50976 },
fe3b50977};
fe3b50978
fe3b50979// --- Parsing ---
fe3b50980
fe3b50981function parseLogSegments(logs: StepLog[]): LogSegment[] {
10943a182 // Join all content first, then split — chunks from Node streams can split
10943a183 // mid-line at 64KB boundaries, so we must concatenate before splitting.
10943a184 const raw = logs.map((l) => l.content).join("");
10943a185 const lines = raw.split("\n");
fe3b50986 const allLines: ParsedLine[] = [];
10943a187 for (let i = 0; i < lines.length; i++) {
10943a188 if (i === lines.length - 1 && lines[i] === "") continue;
10943a189 const text = lines[i];
10943a190 allLines.push({
10943a191 text,
10943a192 displayText: text,
10943a193 level: classifyLine(text),
10943a194 });
fe3b50995 }
fe3b50996
fe3b50997 // Detect Docker mode
fe3b50998 let dockerLineCount = 0;
fe3b50999 for (const line of allLines) {
fe3b509100 if (DOCKER_STEP_RE.test(line.text)) dockerLineCount++;
fe3b509101 if (dockerLineCount >= 3) break;
fe3b509102 }
fe3b509103
fe3b509104 if (dockerLineCount < 3) {
fe3b509105 return [{ type: "plain", lines: allLines }];
fe3b509106 }
fe3b509107
fe3b509108 // Group Docker lines
fe3b509109 const segments: LogSegment[] = [];
fe3b509110 const groupMap = new Map<number, DockerGroup>();
fe3b509111 let plainBuffer: ParsedLine[] = [];
fe3b509112
fe3b509113 for (const line of allLines) {
fe3b509114 const match = line.text.match(DOCKER_STEP_RE);
fe3b509115 if (match) {
fe3b509116 // Flush plain buffer (skip if all empty/whitespace)
fe3b509117 if (plainBuffer.length > 0) {
fe3b509118 if (plainBuffer.some((l) => l.text.trim() !== "")) {
fe3b509119 segments.push({ type: "plain", lines: plainBuffer });
fe3b509120 }
fe3b509121 plainBuffer = [];
fe3b509122 }
fe3b509123
fe3b509124 const stepNum = parseInt(match[1], 10);
fe3b509125 let group = groupMap.get(stepNum);
fe3b509126
fe3b509127 if (!group) {
fe3b509128 group = {
fe3b509129 stepNumber: stepNum,
fe3b509130 headerText: "",
fe3b509131 duration: null,
fe3b509132 lines: [],
fe3b509133 isDone: false,
fe3b509134 hasError: false,
fe3b509135 hasWarning: false,
fe3b509136 };
fe3b509137 groupMap.set(stepNum, group);
fe3b509138 segments.push({ type: "docker-group", group });
fe3b509139 }
fe3b509140
fe3b509141 // Determine header text
fe3b509142 if (!group.headerText && DOCKER_STEP_HEADER_RE.test(line.text)) {
fe3b509143 group.headerText = line.text.replace(DOCKER_STEP_RE, "");
fe3b509144 }
fe3b509145
fe3b509146 // Check for DONE
fe3b509147 const doneMatch = line.text.match(DOCKER_DONE_RE);
fe3b509148 if (doneMatch) {
fe3b509149 group.duration = doneMatch[1];
fe3b509150 group.isDone = true;
fe3b509151 }
fe3b509152
fe3b509153 if (line.level === "error") group.hasError = true;
fe3b509154 if (line.level === "warn") group.hasWarning = true;
fe3b509155 // Strip `#N ` prefix for display — already shown in group header
fe3b509156 line.displayText = line.text.replace(DOCKER_STEP_RE, "");
fe3b509157 group.lines.push(line);
fe3b509158 } else {
fe3b509159 plainBuffer.push(line);
fe3b509160 }
fe3b509161 }
fe3b509162
fe3b509163 // Flush remaining plain buffer (skip if all empty/whitespace in Docker mode)
fe3b509164 if (plainBuffer.length > 0) {
fe3b509165 const hasContent = plainBuffer.some((l) => l.text.trim() !== "");
fe3b509166 if (hasContent) {
fe3b509167 segments.push({ type: "plain", lines: plainBuffer });
fe3b509168 }
fe3b509169 }
fe3b509170
fe3b509171 // Set fallback headers for groups that didn't match the header regex
fe3b509172 for (const group of groupMap.values()) {
fe3b509173 if (!group.headerText && group.lines.length > 0) {
fe3b509174 group.headerText = group.lines[0].text.replace(DOCKER_STEP_RE, "");
fe3b509175 }
fe3b509176 }
fe3b509177
fe3b509178 return segments;
fe3b509179}
fe3b509180
fe3b509181// --- Component ---
fe3b509182
fe3b509183export interface LogViewerProps {
fe3b509184 logs: StepLog[];
fe3b509185 stickToBottom: boolean;
fe3b509186 onStickToBottomChange: (value: boolean) => void;
fe3b509187 style?: React.CSSProperties;
fe3b509188}
fe3b509189
fe3b509190function copyText(text: string): void {
fe3b509191 if (navigator.clipboard?.writeText) {
fe3b509192 navigator.clipboard.writeText(text);
fe3b509193 return;
fe3b509194 }
fe3b509195 const ta = document.createElement("textarea");
fe3b509196 ta.value = text;
fe3b509197 ta.style.position = "fixed";
fe3b509198 ta.style.opacity = "0";
fe3b509199 document.body.appendChild(ta);
fe3b509200 ta.select();
fe3b509201 document.execCommand("copy");
fe3b509202 document.body.removeChild(ta);
fe3b509203}
fe3b509204
fe3b509205const FONT_SIZES = [0.65, 0.7, 0.75, 0.85, 0.95];
fe3b509206const DEFAULT_SIZE_INDEX = 2; // 0.75rem
fe3b509207
fe3b509208export function LogViewer({
fe3b509209 logs,
fe3b509210 stickToBottom,
fe3b509211 onStickToBottomChange,
fe3b509212 style,
fe3b509213}: LogViewerProps) {
fe3b509214 const scrollRef = useRef<HTMLDivElement>(null);
fe3b509215 const [copied, setCopied] = useState(false);
fe3b509216 const [copiedGroup, setCopiedGroup] = useState<number | null>(null);
fe3b509217 const [showScrollBtn, setShowScrollBtn] = useState(false);
fe3b509218 const [fontSizeIdx, setFontSizeIdx] = useState(DEFAULT_SIZE_INDEX);
fe3b509219 const [userToggles, setUserToggles] = useState<Map<number, boolean>>(
fe3b509220 new Map()
fe3b509221 );
fe3b509222
fe3b509223 const segments = useMemo(() => parseLogSegments(logs), [logs]);
fe3b509224
fe3b509225 // Count total lines
fe3b509226 const lineCount = useMemo(() => {
fe3b509227 let count = 0;
fe3b509228 for (const seg of segments) {
fe3b509229 if (seg.type === "plain") count += seg.lines.length;
fe3b509230 else count += seg.group.lines.length;
fe3b509231 }
fe3b509232 return count;
fe3b509233 }, [segments]);
fe3b509234
fe3b509235 // Find the last Docker group step number (for default-expand logic)
fe3b509236 const lastDockerStep = useMemo(() => {
fe3b509237 let last: number | null = null;
fe3b509238 for (const seg of segments) {
fe3b509239 if (seg.type === "docker-group") last = seg.group.stepNumber;
fe3b509240 }
fe3b509241 return last;
fe3b509242 }, [segments]);
fe3b509243
fe3b509244 // Auto-scroll when logs change and stickToBottom is true
fe3b509245 useEffect(() => {
fe3b509246 if (stickToBottom && scrollRef.current) {
fe3b509247 scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
fe3b509248 }
fe3b509249 }, [logs, stickToBottom]);
fe3b509250
fe3b509251 const handleScroll = useCallback(() => {
fe3b509252 const el = scrollRef.current;
fe3b509253 if (!el) return;
fe3b509254 const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
fe3b509255 setShowScrollBtn(distance > 200);
fe3b509256 // Only disable follow on scroll-up, never re-enable from scroll position.
fe3b509257 if (distance > 24) {
fe3b509258 onStickToBottomChange(false);
fe3b509259 }
fe3b509260 }, [onStickToBottomChange]);
fe3b509261
fe3b509262 const scrollToBottom = useCallback(() => {
fe3b509263 if (scrollRef.current) {
fe3b509264 scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
fe3b509265 }
fe3b509266 onStickToBottomChange(true);
fe3b509267 }, [onStickToBottomChange]);
fe3b509268
fe3b509269 const handleCopy = useCallback(() => {
fe3b509270 const allText = logs.map((l) => l.content).join("");
fe3b509271 copyText(allText);
fe3b509272 setCopied(true);
fe3b509273 setTimeout(() => setCopied(false), 2000);
fe3b509274 }, [logs]);
fe3b509275
fe3b509276 const toggleGroup = useCallback((stepNumber: number) => {
fe3b509277 setUserToggles((prev) => {
fe3b509278 const next = new Map(prev);
fe3b509279 const current = next.get(stepNumber);
fe3b509280 if (current === undefined) {
fe3b509281 next.set(stepNumber, true);
fe3b509282 } else {
fe3b509283 next.set(stepNumber, !current);
fe3b509284 }
fe3b509285 return next;
fe3b509286 });
fe3b509287 }, []);
fe3b509288
fe3b509289 const copyGroup = useCallback((group: DockerGroup, e: React.MouseEvent) => {
fe3b509290 e.stopPropagation();
fe3b509291 const text = group.lines.map((l) => l.text).join("\n");
fe3b509292 copyText(text);
fe3b509293 setCopiedGroup(group.stepNumber);
fe3b509294 setTimeout(() => setCopiedGroup(null), 2000);
fe3b509295 }, []);
fe3b509296
fe3b509297 function isGroupCollapsed(group: DockerGroup): boolean {
fe3b509298 const userToggle = userToggles.get(group.stepNumber);
fe3b509299 if (userToggle !== undefined) return userToggle;
fe3b509300 if (group.hasError) return false;
fe3b509301 if (group.stepNumber === lastDockerStep) return false;
fe3b509302 return group.isDone;
fe3b509303 }
fe3b509304
fe3b509305 function groupBorderColor(group: DockerGroup): string {
fe3b509306 if (group.hasError) return "var(--status-closed-border)";
fe3b509307 if (group.hasWarning) return "var(--status-merged-border)";
fe3b509308 if (group.isDone) return "var(--status-open-border)";
fe3b509309 return "var(--accent)";
fe3b509310 }
fe3b509311
fe3b509312 return (
fe3b509313 <div
fe3b509314 style={{
fe3b509315 display: "flex",
fe3b509316 flexDirection: "column",
fe3b509317 ...style,
fe3b509318 }}
fe3b509319 >
fe3b509320 {/* Toolbar */}
fe3b509321 <div
fe3b509322 style={{
fe3b509323 display: "flex",
fe3b509324 alignItems: "center",
fe3b509325 gap: "0.5rem",
fe3b509326 padding: "0.25rem 0.75rem",
fe3b509327 borderBottom: "1px solid var(--divide)",
fe3b509328 fontSize: "0.7rem",
fe3b509329 color: "var(--text-faint)",
fe3b509330 height: "1.75rem",
fe3b509331 flexShrink: 0,
fe3b509332 userSelect: "none",
fe3b509333 }}
fe3b509334 >
fe3b509335 <span>{lineCount.toLocaleString()} lines</span>
fe3b509336 <span style={{ flex: 1 }} />
fe3b509337 <button
fe3b509338 onClick={() => setFontSizeIdx((i) => Math.max(0, i - 1))}
fe3b509339 disabled={fontSizeIdx === 0}
fe3b509340 style={{
fe3b509341 background: "none",
fe3b509342 border: "none",
fe3b509343 cursor: fontSizeIdx === 0 ? "default" : "pointer",
fe3b509344 font: "inherit",
fe3b509345 color: fontSizeIdx === 0 ? "var(--divide)" : "var(--text-faint)",
fe3b509346 padding: "0.125rem 0.25rem",
fe3b509347 fontSize: "0.8rem",
fe3b509348 lineHeight: 1,
fe3b509349 }}
fe3b509350 title="Decrease font size"
fe3b509351 >
fe3b509352 A−
fe3b509353 </button>
fe3b509354 <button
fe3b509355 onClick={() => setFontSizeIdx(DEFAULT_SIZE_INDEX)}
fe3b509356 disabled={fontSizeIdx === DEFAULT_SIZE_INDEX}
fe3b509357 style={{
fe3b509358 background: "none",
fe3b509359 border: "none",
fe3b509360 cursor: fontSizeIdx !== DEFAULT_SIZE_INDEX ? "pointer" : "default",
fe3b509361 font: "inherit",
fe3b509362 color: fontSizeIdx !== DEFAULT_SIZE_INDEX ? "var(--text-faint)" : "var(--border)",
fe3b509363 padding: "0.125rem 0.25rem",
fe3b509364 fontSize: "0.7rem",
fe3b509365 lineHeight: 1,
fe3b509366 }}
fe3b509367 title="Reset font size"
fe3b509368 >
fe3b509369 Reset
fe3b509370 </button>
fe3b509371 <button
fe3b509372 onClick={() => setFontSizeIdx((i) => Math.min(FONT_SIZES.length - 1, i + 1))}
fe3b509373 disabled={fontSizeIdx === FONT_SIZES.length - 1}
fe3b509374 style={{
fe3b509375 background: "none",
fe3b509376 border: "none",
fe3b509377 cursor: fontSizeIdx === FONT_SIZES.length - 1 ? "default" : "pointer",
fe3b509378 font: "inherit",
fe3b509379 color: fontSizeIdx === FONT_SIZES.length - 1 ? "var(--divide)" : "var(--text-faint)",
fe3b509380 padding: "0.125rem 0.25rem",
fe3b509381 fontSize: "0.8rem",
fe3b509382 lineHeight: 1,
fe3b509383 }}
fe3b509384 title="Increase font size"
fe3b509385 >
fe3b509386 A+
fe3b509387 </button>
fe3b509388 <span style={{ width: "1px", height: "0.75rem", backgroundColor: "var(--divide)" }} />
fe3b509389 <button
fe3b509390 onClick={handleCopy}
fe3b509391 style={{
fe3b509392 background: "none",
fe3b509393 border: "none",
fe3b509394 cursor: "pointer",
fe3b509395 font: "inherit",
fe3b509396 color: copied ? "var(--accent)" : "var(--text-faint)",
fe3b509397 padding: "0.125rem 0.375rem",
fe3b509398 }}
fe3b509399 >
fe3b509400 {copied ? "Copied!" : "Copy"}
fe3b509401 </button>
fe3b509402 <button
fe3b509403 onClick={() => {
fe3b509404 if (!stickToBottom) scrollToBottom();
fe3b509405 else onStickToBottomChange(false);
fe3b509406 }}
fe3b509407 style={{
fe3b509408 background: "none",
fe3b509409 border: "none",
fe3b509410 cursor: "pointer",
fe3b509411 font: "inherit",
fe3b509412 color: stickToBottom ? "var(--accent)" : "var(--text-faint)",
fe3b509413 padding: "0.125rem 0.375rem",
fe3b509414 }}
fe3b509415 title={
fe3b509416 stickToBottom
fe3b509417 ? "Following output (click to stop)"
fe3b509418 : "Click to follow output"
fe3b509419 }
fe3b509420 >
fe3b509421 {stickToBottom ? "Following ↓" : "Follow"}
fe3b509422 </button>
fe3b509423 </div>
fe3b509424
fe3b509425 {/* Scrollable log area */}
fe3b509426 <div
fe3b509427 ref={scrollRef}
fe3b509428 onScroll={handleScroll}
fe3b509429 style={{
fe3b509430 flex: 1,
fe3b509431 overflowY: "auto",
fe3b509432 overflowX: "hidden",
fe3b509433 position: "relative",
fe3b509434 minHeight: 0,
fe3b509435 fontSize: `${FONT_SIZES[fontSizeIdx]}rem`,
fe3b509436 lineHeight: 1.5,
fe3b509437 fontFamily: "var(--font-mono, ui-monospace, monospace)",
fe3b509438 }}
fe3b509439 >
fe3b509440 <div style={{ padding: "0.5rem 0" }}>
fe3b509441 {segments.map((seg, i) => {
fe3b509442 if (seg.type === "plain") {
fe3b509443 return (
fe3b509444 <div key={`p-${i}`}>
fe3b509445 {seg.lines.map((line, j) => (
fe3b509446 <LogLine key={j} line={line} />
fe3b509447 ))}
fe3b509448 </div>
fe3b509449 );
fe3b509450 }
fe3b509451
fe3b509452 const { group } = seg;
fe3b509453 const collapsed = isGroupCollapsed(group);
fe3b509454 const borderColor = groupBorderColor(group);
fe3b509455
fe3b509456 return (
fe3b509457 <div key={`g-${group.stepNumber}`} className="docker-group" style={{ marginBottom: "0.125rem" }}>
fe3b509458 {/* Group header */}
fe3b509459 <div
fe3b509460 className="docker-group-header"
fe3b509461 onClick={() => toggleGroup(group.stepNumber)}
fe3b509462 style={{
fe3b509463 display: "flex",
fe3b509464 alignItems: "center",
fe3b509465 gap: "0.5rem",
fe3b509466 cursor: "pointer",
fe3b509467 padding: "0.4rem 0.75rem",
fe3b509468 userSelect: "none",
fe3b509469 position: "sticky",
fe3b509470 top: 0,
fe3b509471 backgroundColor: "var(--bg-page)",
fe3b509472 zIndex: 1,
fe3b509473 borderBottom: "1px solid var(--divide)",
fe3b509474 borderLeft: `3px solid ${borderColor}`,
fe3b509475 transition: "background-color 0.1s",
fe3b509476 }}
fe3b509477 >
fe3b509478 <span
fe3b509479 style={{
fe3b509480 color: "var(--text-faint)",
fe3b509481 width: "0.75em",
fe3b509482 flexShrink: 0,
fe3b509483 fontSize: "0.9em",
fe3b509484 }}
fe3b509485 >
fe3b509486 {collapsed ? "▸" : "▾"}
fe3b509487 </span>
fe3b509488 <span
fe3b509489 style={{
fe3b509490 color: "var(--text-primary)",
fe3b509491 fontWeight: 600,
fe3b509492 flexShrink: 0,
fe3b509493 fontSize: "1em",
fe3b509494 }}
fe3b509495 >
fe3b509496 #{group.stepNumber}
fe3b509497 </span>
fe3b509498 <span
fe3b509499 style={{
fe3b509500 color: group.hasError
fe3b509501 ? "var(--status-closed-text)"
fe3b509502 : "var(--text-primary)",
fe3b509503 flex: 1,
fe3b509504 minWidth: 0,
fe3b509505 overflow: "hidden",
fe3b509506 textOverflow: "ellipsis",
fe3b509507 whiteSpace: "nowrap",
fe3b509508 fontWeight: 500,
fe3b509509 }}
fe3b509510 >
fe3b509511 {group.headerText}
fe3b509512 </span>
fe3b509513 <button
fe3b509514 className="docker-group-copy"
fe3b509515 onClick={(e) => copyGroup(group, e)}
fe3b509516 style={{
fe3b509517 background: "none",
fe3b509518 border: "none",
fe3b509519 cursor: "pointer",
fe3b509520 font: "inherit",
fe3b509521 color: copiedGroup === group.stepNumber ? "var(--accent)" : "var(--text-faint)",
fe3b509522 padding: "0.125rem 0.375rem",
fe3b509523 fontSize: "0.7rem",
fe3b509524 flexShrink: 0,
fe3b509525 transition: "opacity 0.15s",
fe3b509526 }}
fe3b509527 >
fe3b509528 {copiedGroup === group.stepNumber ? "Copied" : "Copy"}
fe3b509529 </button>
fe3b509530 {group.duration && (
fe3b509531 <span
fe3b509532 style={{
fe3b509533 color: "var(--text-faint)",
fe3b509534 flexShrink: 0,
fe3b509535 fontSize: "0.9em",
fe3b509536 }}
fe3b509537 >
fe3b509538 {group.duration}
fe3b509539 </span>
fe3b509540 )}
fe3b509541 <span
fe3b509542 style={{
fe3b509543 color: "var(--text-faint)",
fe3b509544 flexShrink: 0,
fe3b509545 fontSize: "0.65rem",
fe3b509546 }}
fe3b509547 >
fe3b509548 {group.lines.length} lines
fe3b509549 </span>
fe3b509550 </div>
fe3b509551 {/* Group body */}
fe3b509552 {!collapsed && (
fe3b509553 <div
fe3b509554 className="docker-group-body"
fe3b509555 style={{
fe3b509556 borderLeft: `3px solid color-mix(in srgb, ${borderColor} 40%, transparent)`,
fe3b509557 }}
fe3b509558 >
fe3b509559 {group.lines.map((line, j) => (
fe3b509560 <LogLine key={j} line={line} />
fe3b509561 ))}
fe3b509562 </div>
fe3b509563 )}
fe3b509564 </div>
fe3b509565 );
fe3b509566 })}
fe3b509567 </div>
fe3b509568
fe3b509569 {/* Scroll to bottom button */}
fe3b509570 {showScrollBtn && (
fe3b509571 <button
fe3b509572 onClick={scrollToBottom}
fe3b509573 style={{
fe3b509574 position: "sticky",
fe3b509575 bottom: "0.75rem",
fe3b509576 float: "right",
fe3b509577 marginRight: "0.75rem",
fe3b509578 backgroundColor: "var(--bg-card)",
fe3b509579 border: "1px solid var(--border)",
fe3b509580 color: "var(--text-muted)",
fe3b509581 padding: "0.25rem 0.625rem",
fe3b509582 fontSize: "0.7rem",
fe3b509583 cursor: "pointer",
fe3b509584 boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
fe3b509585 }}
fe3b509586 >
fe3b509587 ↓ Bottom
fe3b509588 </button>
fe3b509589 )}
fe3b509590 </div>
fe3b509591 </div>
fe3b509592 );
fe3b509593}
fe3b509594
fe3b509595function LogLine({ line }: { line: ParsedLine }) {
fe3b509596 const lc = levelColors[line.level];
fe3b509597 return (
fe3b509598 <div
fe3b509599 className="log-line"
fe3b509600 style={{
fe3b509601 padding: "0 0.75rem",
fe3b509602 whiteSpace: "pre-wrap",
10943a1603 overflowWrap: "anywhere",
fe3b509604 color: lc.color,
fe3b509605 backgroundColor: lc.bg,
fe3b509606 borderLeft: line.level !== "plain" ? `3px solid ${lc.border}` : undefined,
fe3b509607 marginLeft: line.level !== "plain" ? "-3px" : undefined,
fe3b509608 tabSize: 2,
fe3b509609 }}
fe3b509610 >
fe3b509611 {line.displayText}
fe3b509612 </div>
fe3b509613 );
fe3b509614}