Skip to content
File

Blob: src/client/components/log-viewer.tsx

typescript705 lines
1import { ArrowDown, ChevronDown, ChevronUp, Search, WrapText, X } from "lucide-react";
2import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3 
4import type { LogEvent, LogStream } from "@/contracts";
5import { createAnsiProcessor, type AnsiProcessor, type AnsiSpan } from "@/client/lib";
6import { isLogViewerPinnedToLatest } from "@/client/components/log-viewer-scroll";
7 
8const STREAM_COLORS: Record<string, string> = {
9 stdout: "text-zinc-300",
10 stderr: "text-red-400",
11 system: "text-accent-400",
12};
13 
14const STREAM_DOT: Record<string, string> = {
15 stdout: "bg-zinc-400",
16 stderr: "bg-red-400",
17 system: "bg-accent-400",
18};
19 
20const CONNECTION_DOT: Record<string, string> = {
21 connected: "bg-emerald-500",
22 connecting: "bg-amber-500 animate-pulse",
23 reconnecting: "bg-amber-500 animate-pulse",
24 closed: "bg-zinc-500",
25 idle: "bg-zinc-500",
26};
27 
28const ALL_STREAMS: readonly LogStream[] = ["stdout", "stderr", "system"];
29const BASE_PADDING_BOTTOM_PX = 16;
30const JUMP_PADDING_BOTTOM_PX = 48;
31 
32interface HighlightRange {
33 start: number;
34 end: number;
35 matchIndex: number;
36}
37 
38interface FilteredEntry {
39 log: LogEvent;
40 spans: AnsiSpan[];
41 index: number;
42}
43 
44interface ProcessedLogCache {
45 proc: AnsiProcessor;
46 results: AnsiSpan[][];
47 firstId: string | null;
48 lastId: string | null;
49}
50 
51interface MatchPosition {
52 entryIndex: number;
53 charStart: number;
54 charEnd: number;
55}
56 
57const isUnstyled = (s: AnsiSpan) =>
58 !s.fg && !s.bg && !s.bold && !s.dim && !s.italic && !s.underline && !s.strikethrough;
59 
60const spanStyle = (span: AnsiSpan): React.CSSProperties | undefined => {
61 if (isUnstyled(span)) return undefined;
62 const s: React.CSSProperties = {};
63 if (span.fg) s.color = span.fg;
64 if (span.bg) s.backgroundColor = span.bg;
65 if (span.bold) s.fontWeight = "bold";
66 if (span.dim) s.opacity = 0.5;
67 if (span.italic) s.fontStyle = "italic";
68 const deco = [span.underline && "underline", span.strikethrough && "line-through"].filter(Boolean).join(" ");
69 if (deco) s.textDecorationLine = deco;
70 return s;
71};
72 
73const canAppendProcessedLogs = (cache: ProcessedLogCache, logs: LogEvent[]) => {
74 if (cache.results.length === 0 || logs.length <= cache.results.length) {
75 return false;
76 }
77 
78 if (logs[0]!.id !== cache.firstId) {
79 return false;
80 }
81 
82 return logs[cache.results.length - 1]!.id === cache.lastId;
83};
84 
85/**
86 * Render a single chunk's ANSI spans, optionally splitting them at search
87 * highlight boundaries.
88 */
89const AnsiLine = memo(
90 function AnsiLine({
91 spans,
92 className,
93 highlights,
94 currentMatchIndex,
95 }: {
96 spans: AnsiSpan[];
97 className: string;
98 highlights?: HighlightRange[];
99 currentMatchIndex?: number;
100 }) {
101 // Fast path: no highlights, single unstyled span
102 if (!highlights && spans.length === 1 && isUnstyled(spans[0]!)) {
103 return <span className={className}>{spans[0]!.text}</span>;
104 }
105 
106 // No highlights — render spans without splitting
107 if (!highlights) {
108 return (
109 <span className={className}>
110 {spans.map((span, i) => {
111 const s = spanStyle(span);
112 return s ? (
113 <span key={i} style={s}>
114 {span.text}
115 </span>
116 ) : (
117 span.text
118 );
119 })}
120 </span>
121 );
122 }
123 
124 // With highlights — split spans at highlight boundaries
125 const elements: React.ReactNode[] = [];
126 let charOffset = 0;
127 let hi = 0; // highlight pointer
128 let key = 0;
129 
130 for (const span of spans) {
131 const s = spanStyle(span);
132 const spanEnd = charOffset + span.text.length;
133 let pos = 0; // position within span.text
134 
135 while (pos < span.text.length && hi < highlights.length) {
136 const h = highlights[hi]!;
137 const absPos = charOffset + pos;
138 
139 // Before highlight start — emit plain segment
140 if (absPos < h.start) {
141 const segEnd = Math.min(span.text.length, h.start - charOffset);
142 const seg = span.text.slice(pos, segEnd);
143 if (seg)
144 elements.push(
145 s ? (
146 <span key={key++} style={s}>
147 {seg}
148 </span>
149 ) : (
150 seg
151 ),
152 );
153 pos = segEnd;
154 continue;
155 }
156 
157 // Inside highlight — emit marked segment
158 if (absPos < h.end) {
159 const segEnd = Math.min(span.text.length, h.end - charOffset);
160 const seg = span.text.slice(pos, segEnd);
161 if (seg) {
162 elements.push(
163 <mark
164 key={key++}
165 className={
166 h.matchIndex === currentMatchIndex
167 ? "rounded-sm bg-amber-400/40 text-inherit ring-1 ring-amber-400/60"
168 : "rounded-sm bg-amber-500/25 text-inherit"
169 }
170 style={s}
171 >
172 {seg}
173 </mark>,
174 );
175 }
176 pos = segEnd;
177 if (charOffset + pos >= h.end) hi++;
178 continue;
179 }
180 
181 // Past this highlight — advance
182 hi++;
183 }
184 
185 // Remaining text after all highlights
186 if (pos < span.text.length) {
187 const seg = span.text.slice(pos);
188 elements.push(
189 s ? (
190 <span key={key++} style={s}>
191 {seg}
192 </span>
193 ) : (
194 seg
195 ),
196 );
197 }
198 
199 charOffset = spanEnd;
200 }
201 
202 return <span className={className}>{elements}</span>;
203 },
204 (prev, next) => {
205 if (prev.spans !== next.spans || prev.className !== next.className) return false;
206 if (prev.highlights !== next.highlights) return false;
207 if (!prev.highlights) return true;
208 if (prev.currentMatchIndex === next.currentMatchIndex) return true;
209 const affects = (idx: number | undefined) =>
210 idx !== undefined && prev.highlights!.some((h) => h.matchIndex === idx);
211 return !affects(prev.currentMatchIndex) && !affects(next.currentMatchIndex);
212 },
213);
214 
215/** Search bar – owns local input state + debounce; parent only sees the settled query. */
216const SearchBar = memo(function SearchBar({
217 searchActive,
218 matchCount,
219 currentMatchIndex,
220 onQueryChange,
221 onNext,
222 onPrev,
223 onClose,
224}: {
225 searchActive: boolean;
226 matchCount: number;
227 currentMatchIndex: number;
228 onQueryChange: (query: string) => void;
229 onNext: () => void;
230 onPrev: () => void;
231 onClose: () => void;
232}) {
233 const inputRef = useRef<HTMLInputElement>(null);
234 const [query, setQuery] = useState("");
235 const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
236 
237 useEffect(() => {
238 clearTimeout(debounceRef.current);
239 if (!query) {
240 onQueryChange("");
241 return;
242 }
243 debounceRef.current = setTimeout(() => onQueryChange(query), 500);
244 return () => clearTimeout(debounceRef.current);
245 }, [query, onQueryChange]);
246 
247 useEffect(() => {
248 inputRef.current?.focus();
249 }, []);
250 
251 const handleKeyDown = useCallback(
252 (e: React.KeyboardEvent) => {
253 if (e.key === "Enter") {
254 e.preventDefault();
255 if (e.shiftKey) onPrev();
256 else onNext();
257 }
258 if (e.key === "Escape") {
259 e.preventDefault();
260 onClose();
261 }
262 },
263 [onNext, onPrev, onClose],
264 );
265 
266 return (
267 <div
268 role="search"
269 aria-label="Search logs"
270 className="flex items-center gap-1.5 border-t border-zinc-800/40 px-4 py-1.5"
271 >
272 <Search className="h-3.5 w-3.5 shrink-0 text-zinc-500" aria-hidden="true" />
273 <input
274 ref={inputRef}
275 type="text"
276 value={query}
277 onChange={(e) => setQuery(e.target.value)}
278 onKeyDown={handleKeyDown}
279 placeholder="Search logs..."
280 className="min-w-0 flex-1 bg-transparent text-xs text-zinc-100 placeholder:text-zinc-600 outline-none"
281 aria-label="Search log text"
282 />
283 {searchActive ? (
284 <span className="shrink-0 text-[11px] tabular-nums text-zinc-500" aria-live="polite">
285 {matchCount > 0 ? `${currentMatchIndex + 1} of ${matchCount}` : "No matches"}
286 </span>
287 ) : null}
288 <button
289 type="button"
290 aria-label="Previous match"
291 onClick={onPrev}
292 disabled={matchCount === 0}
293 className="rounded p-0.5 text-zinc-500 hover:text-zinc-300 disabled:opacity-30"
294 >
295 <ChevronUp className="h-3.5 w-3.5" />
296 </button>
297 <button
298 type="button"
299 aria-label="Next match"
300 onClick={onNext}
301 disabled={matchCount === 0}
302 className="rounded p-0.5 text-zinc-500 hover:text-zinc-300 disabled:opacity-30"
303 >
304 <ChevronDown className="h-3.5 w-3.5" />
305 </button>
306 <button
307 type="button"
308 aria-label="Close search"
309 onClick={onClose}
310 className="rounded p-0.5 text-zinc-500 hover:text-zinc-300"
311 >
312 <X className="h-3.5 w-3.5" />
313 </button>
314 </div>
315 );
316});
317 
318const LogViewer = memo(function LogViewer({ logs, logStreamStatus }: { logs: LogEvent[]; logStreamStatus: string }) {
319 // -- Refs ----------------------------------------------------------------
320 const logContainerRef = useRef<HTMLDivElement>(null);
321 const autoScrollRef = useRef(true);
322 const scrollingToBottomRef = useRef(false);
323 const sectionRef = useRef<HTMLElement>(null);
324 const matchLineRefs = useRef<Map<number, HTMLDivElement>>(new Map());
325 const paddingBottomRef = useRef(BASE_PADDING_BOTTOM_PX);
326 
327 // -- Scroll state --------------------------------------------------------
328 const [atBottom, setAtBottom] = useState(true);
329 
330 const handleScroll = useCallback(() => {
331 const container = logContainerRef.current;
332 if (!container) return;
333 const isAtBottom = isLogViewerPinnedToLatest({
334 scrollTop: container.scrollTop,
335 clientHeight: container.clientHeight,
336 scrollHeight: container.scrollHeight,
337 paddingBottom: paddingBottomRef.current,
338 });
339 setAtBottom(isAtBottom);
340 
341 if (isAtBottom) {
342 scrollingToBottomRef.current = false;
343 autoScrollRef.current = true;
344 return;
345 }
346 
347 if (!scrollingToBottomRef.current) {
348 autoScrollRef.current = false;
349 }
350 }, []);
351 
352 const scrollToBottom = useCallback(() => {
353 const container = logContainerRef.current;
354 if (!container) return;
355 scrollingToBottomRef.current = true;
356 autoScrollRef.current = true;
357 container.scrollTo({ top: container.scrollHeight, behavior: "smooth" });
358 }, []);
359 
360 const handleScrollTakeover = useCallback(() => {
361 if (!scrollingToBottomRef.current) return;
362 const container = logContainerRef.current;
363 scrollingToBottomRef.current = false;
364 if (!container) {
365 autoScrollRef.current = false;
366 return;
367 }
368 
369 const isAtBottom = isLogViewerPinnedToLatest({
370 scrollTop: container.scrollTop,
371 clientHeight: container.clientHeight,
372 scrollHeight: container.scrollHeight,
373 paddingBottom: paddingBottomRef.current,
374 });
375 setAtBottom(isAtBottom);
376 autoScrollRef.current = isAtBottom;
377 }, []);
378 
379 // Auto-scroll on new logs
380 useLayoutEffect(() => {
381 const container = logContainerRef.current;
382 if (!container || !autoScrollRef.current) return;
383 container.scrollTop = container.scrollHeight;
384 const isAtBottom = isLogViewerPinnedToLatest({
385 scrollTop: container.scrollTop,
386 clientHeight: container.clientHeight,
387 scrollHeight: container.scrollHeight,
388 paddingBottom: paddingBottomRef.current,
389 });
390 setAtBottom(isAtBottom);
391 if (isAtBottom) {
392 scrollingToBottomRef.current = false;
393 }
394 }, [logs]);
395 
396 // -- Incremental ANSI processing -----------------------------------------
397 const processorRef = useRef<ProcessedLogCache>({
398 proc: createAnsiProcessor(),
399 results: [],
400 firstId: null,
401 lastId: null,
402 });
403 
404 // Incremental processing — imperative ref update (idempotent, StrictMode-safe)
405 const cache = processorRef.current;
406 if (logs.length === 0) {
407 if (cache.results.length > 0) {
408 processorRef.current = { proc: createAnsiProcessor(), results: [], firstId: null, lastId: null };
409 }
410 } else if (
411 cache.firstId === logs[0]!.id &&
412 cache.lastId === logs[logs.length - 1]!.id &&
413 cache.results.length === logs.length
414 ) {
415 // Already fully processed (double-invocation or unchanged logs) — no-op
416 } else if (canAppendProcessedLogs(cache, logs)) {
417 for (let i = cache.results.length; i < logs.length; i++) {
418 cache.results.push(cache.proc.feed(logs[i]!.chunk));
419 }
420 cache.lastId = logs[logs.length - 1]!.id;
421 } else {
422 const proc = createAnsiProcessor();
423 const results = logs.map((log) => proc.feed(log.chunk));
424 processorRef.current = { proc, results, firstId: logs[0]!.id, lastId: logs[logs.length - 1]!.id };
425 }
426 const processedLogs = processorRef.current.results;
427 
428 // -- Stream filtering ----------------------------------------------------
429 const [visibleStreams, setVisibleStreams] = useState<Set<LogStream>>(new Set(ALL_STREAMS));
430 
431 const streamCounts = useMemo(() => {
432 const counts: Record<string, number> = { stdout: 0, stderr: 0, system: 0 };
433 for (const log of logs) counts[log.stream]++;
434 return counts;
435 }, [logs]);
436 
437 const toggleStream = useCallback((stream: LogStream) => {
438 setVisibleStreams((prev) => {
439 const next = new Set(prev);
440 if (next.has(stream)) next.delete(stream);
441 else next.add(stream);
442 return next;
443 });
444 }, []);
445 
446 const filteredEntries = useMemo(() => {
447 const entries: FilteredEntry[] = [];
448 for (let i = 0; i < logs.length; i++) {
449 if (visibleStreams.has(logs[i]!.stream)) {
450 entries.push({ log: logs[i]!, spans: processedLogs[i]!, index: i });
451 }
452 }
453 return entries;
454 }, [logs, processedLogs, visibleStreams]);
455 
456 // -- Text wrap -----------------------------------------------------------
457 const [textWrap, setTextWrap] = useState(true);
458 
459 // -- Search --------------------------------------------------------------
460 const [searchOpen, setSearchOpen] = useState(false);
461 const [searchQuery, setSearchQuery] = useState("");
462 const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
463 
464 const searchMatches = useMemo(() => {
465 if (!searchQuery) return [];
466 const matches: MatchPosition[] = [];
467 const needle = searchQuery.toLowerCase();
468 for (let ei = 0; ei < filteredEntries.length; ei++) {
469 const text = filteredEntries[ei]!.spans.map((s) => s.text)
470 .join("")
471 .toLowerCase();
472 let pos = 0;
473 while (pos <= text.length - needle.length) {
474 const idx = text.indexOf(needle, pos);
475 if (idx === -1) break;
476 matches.push({ entryIndex: ei, charStart: idx, charEnd: idx + needle.length });
477 pos = idx + 1;
478 }
479 }
480 return matches;
481 }, [filteredEntries, searchQuery]);
482 
483 // Clamp match index
484 useEffect(() => {
485 if (searchMatches.length === 0) {
486 setCurrentMatchIndex(0);
487 } else if (currentMatchIndex >= searchMatches.length) {
488 setCurrentMatchIndex(searchMatches.length - 1);
489 }
490 }, [searchMatches.length, currentMatchIndex]);
491 
492 // Scroll to current match
493 useEffect(() => {
494 if (searchMatches.length === 0) return;
495 const match = searchMatches[currentMatchIndex];
496 if (!match) return;
497 const el = matchLineRefs.current.get(match.entryIndex);
498 if (el) {
499 scrollingToBottomRef.current = false;
500 el.scrollIntoView({ block: "nearest", behavior: "smooth" });
501 autoScrollRef.current = false;
502 }
503 }, [currentMatchIndex, searchMatches]);
504 
505 // Highlight map: entryIndex → HighlightRange[] (stable across match navigation)
506 const lineHighlights = useMemo(() => {
507 if (!searchQuery || searchMatches.length === 0) return null;
508 const map = new Map<number, HighlightRange[]>();
509 for (let mi = 0; mi < searchMatches.length; mi++) {
510 const m = searchMatches[mi]!;
511 let list = map.get(m.entryIndex);
512 if (!list) {
513 list = [];
514 map.set(m.entryIndex, list);
515 }
516 list.push({ start: m.charStart, end: m.charEnd, matchIndex: mi });
517 }
518 return map;
519 }, [searchQuery, searchMatches]);
520 
521 const goToNextMatch = useCallback(() => {
522 if (searchMatches.length === 0) return;
523 setCurrentMatchIndex((i) => (i + 1) % searchMatches.length);
524 }, [searchMatches.length]);
525 
526 const goToPrevMatch = useCallback(() => {
527 if (searchMatches.length === 0) return;
528 setCurrentMatchIndex((i) => (i - 1 + searchMatches.length) % searchMatches.length);
529 }, [searchMatches.length]);
530 
531 const openSearch = useCallback(() => setSearchOpen(true), []);
532 
533 const closeSearch = useCallback(() => {
534 setSearchOpen(false);
535 setSearchQuery("");
536 setCurrentMatchIndex(0);
537 matchLineRefs.current.clear();
538 }, []);
539 
540 const handleQueryChange = useCallback((q: string) => {
541 setSearchQuery(q);
542 setCurrentMatchIndex(0);
543 }, []);
544 
545 // Ctrl/Cmd+F shortcut (guarded to viewport)
546 useEffect(() => {
547 const handler = (e: KeyboardEvent) => {
548 if ((e.ctrlKey || e.metaKey) && e.key === "f") {
549 const section = sectionRef.current;
550 if (!section) return;
551 const rect = section.getBoundingClientRect();
552 if (rect.bottom < 0 || rect.top > window.innerHeight) return;
553 e.preventDefault();
554 openSearch();
555 }
556 };
557 document.addEventListener("keydown", handler);
558 return () => document.removeEventListener("keydown", handler);
559 }, [openSearch]);
560 
561 // -- Render --------------------------------------------------------------
562 const showJumpButton = !atBottom && filteredEntries.length > 0;
563 paddingBottomRef.current = showJumpButton ? JUMP_PADDING_BOTTOM_PX : BASE_PADDING_BOTTOM_PX;
564 
565 return (
566 <section
567 ref={sectionRef}
568 className="flex min-h-[400px] max-h-[60vh] flex-col overflow-hidden rounded-2xl border border-zinc-800/60 bg-zinc-950/60 sm:max-h-[70vh] lg:sticky lg:top-16 lg:max-h-[calc(100vh-5rem)]"
569 >
570 {/* Header */}
571 <div className="shrink-0 border-b border-zinc-800/60">
572 {/* Top row: title + search icon + connection status */}
573 <div className="flex items-center gap-3 px-4 py-2">
574 <h2 className="text-sm font-semibold text-zinc-100">Logs</h2>
575
576 {/* Stream filter chips */}
577 <div className="flex items-center gap-1.5" role="group" aria-label="Filter by stream">
578 {ALL_STREAMS.map((stream) => {
579 const active = visibleStreams.has(stream);
580 return (
581 <button
582 key={stream}
583 type="button"
584 aria-pressed={active}
585 aria-label={`${stream} logs (${streamCounts[stream] ?? 0})`}
586 onClick={() => toggleStream(stream)}
587 className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] transition-colors ${
588 active
589 ? "border-zinc-600 bg-zinc-800 text-zinc-300"
590 : "border-zinc-800/60 bg-transparent text-zinc-600"
591 }`}
592 >
593 <span className={`h-1.5 w-1.5 rounded-full ${STREAM_DOT[stream] ?? "bg-zinc-500"}`} />
594 {stream}
595 <span className="tabular-nums text-zinc-500">{streamCounts[stream] ?? 0}</span>
596 </button>
597 );
598 })}
599 </div>
600
601 <div className="flex-1" />
602
603 {/* Search toggle */}
604 {!searchOpen ? (
605 <button
606 type="button"
607 aria-label="Search logs"
608 onClick={openSearch}
609 className="rounded p-1 text-zinc-500 hover:text-zinc-300"
610 >
611 <Search className="h-3.5 w-3.5" />
612 </button>
613 ) : null}
614
615 {/* Wrap toggle */}
616 <button
617 type="button"
618 aria-pressed={textWrap}
619 aria-label="Toggle text wrap"
620 onClick={() => setTextWrap((v) => !v)}
621 className={`rounded p-1 ${textWrap ? "text-zinc-300" : "text-zinc-500"} hover:text-zinc-300`}
622 >
623 <WrapText className="h-3.5 w-3.5" />
624 </button>
625
626 {/* Connection status */}
627 <span className="inline-flex items-center gap-2 text-xs text-zinc-500">
628 <span className={`h-2 w-2 rounded-full ${CONNECTION_DOT[logStreamStatus] ?? "bg-zinc-500"}`} />
629 {logStreamStatus}
630 </span>
631 </div>
632
633 {/* Search bar (separate row when open) */}
634 {searchOpen ? (
635 <SearchBar
636 searchActive={searchQuery !== ""}
637 matchCount={searchMatches.length}
638 currentMatchIndex={currentMatchIndex}
639 onQueryChange={handleQueryChange}
640 onNext={goToNextMatch}
641 onPrev={goToPrevMatch}
642 onClose={closeSearch}
643 />
644 ) : null}
645 </div>
646
647 {/* Log scroll area + jump-to-bottom */}
648 <div className="relative min-h-0 flex-1">
649 <div
650 ref={logContainerRef}
651 onScroll={handleScroll}
652 onPointerDown={handleScrollTakeover}
653 onTouchStart={handleScrollTakeover}
654 onWheel={handleScrollTakeover}
655 className={`h-full overflow-auto p-4 font-mono text-xs leading-5 ${showJumpButton ? "pb-12" : ""}`}
656 >
657 {filteredEntries.length === 0 ? (
658 <p className="text-zinc-600">
659 {logs.length === 0 ? "No log output yet." : "No logs match the current filters."}
660 </p>
661 ) : (
662 filteredEntries.map((entry, i) => (
663 <div
664 key={entry.log.id}
665 ref={
666 searchOpen
667 ? (el: HTMLDivElement | null) => {
668 if (el) matchLineRefs.current.set(i, el);
669 else matchLineRefs.current.delete(i);
670 }
671 : undefined
672 }
673 className={`flex gap-3${textWrap ? " min-w-0" : " whitespace-nowrap"}`}
674 >
675 <span className="w-8 shrink-0 select-none text-right text-zinc-600">{entry.log.seq}</span>
676 <AnsiLine
677 spans={entry.spans}
678 className={`${STREAM_COLORS[entry.log.stream] ?? "text-zinc-300"}${textWrap ? " break-all" : ""}`}
679 highlights={lineHighlights?.get(i)}
680 currentMatchIndex={currentMatchIndex}
681 />
682 </div>
683 ))
684 )}
685 </div>
686
687 {/* Jump to bottom */}
688 {showJumpButton ? (
689 <button
690 type="button"
691 aria-label="Scroll to latest logs"
692 onClick={scrollToBottom}
693 className="absolute bottom-3 right-3 z-10 flex items-center gap-1.5 rounded-lg border border-zinc-700/60 bg-zinc-800/90 px-3 py-1.5 text-xs font-medium text-zinc-300 shadow-lg backdrop-blur-sm transition-opacity hover:bg-zinc-700/60 hover:text-zinc-100 max-sm:inset-x-3 max-sm:right-auto"
694 >
695 <ArrowDown className="h-3.5 w-3.5" />
696 Latest
697 </button>
698 ) : null}
699 </div>
700 </section>
701 );
702});
703 
704export { LogViewer };