feat: Add pause/auto-scroll toggle to the log viewer
New lines auto-scroll the view to the bottom; scrolling up to read now pauses that (an amber 'paused' marker shows), and scrolling back to the bottom resumes — the explicit Pause/Play button and the scroll position stay in sync via a single at-bottom check. While paused, logs still stream in below without moving the view. Jump-to-latest is instant rather than smooth so mid-animation positions never register as a false scroll-up.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useImperativeHandle, useRef, useState, forwardRef } from "react"
|
||||
import { Maximize2, Minimize2, Trash2 } from "lucide-react"
|
||||
import { Maximize2, Minimize2, Pause, Play, Trash2 } from "lucide-react"
|
||||
import { apiClient } from "@/services/api/client"
|
||||
|
||||
interface LogViewerProps {
|
||||
@@ -19,7 +19,11 @@ export const LogViewer = forwardRef<LogViewerHandle, LogViewerProps>(function Lo
|
||||
const [logs, setLogs] = useState<string[]>([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [maximized, setMaximized] = useState(false)
|
||||
// When true, new log lines scroll the view to the bottom. Scrolling up to read
|
||||
// pauses it; scrolling back to the bottom (or the play button) resumes.
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const preRef = useRef<HTMLPreElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!follow) {
|
||||
@@ -53,9 +57,20 @@ export const LogViewer = forwardRef<LogViewerHandle, LogViewerProps>(function Lo
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [maximized])
|
||||
|
||||
// Jump to the newest line only while auto-scroll is on. Instant (not smooth) so
|
||||
// the scroll-position check below never sees a mid-animation false "scrolled up".
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [logs])
|
||||
if (autoScroll) bottomRef.current?.scrollIntoView()
|
||||
}, [logs, autoScroll])
|
||||
|
||||
// Keep auto-scroll in sync with where the user is: away from the bottom pauses,
|
||||
// back at the bottom resumes — so the button and the scroll position never disagree.
|
||||
const onScroll = () => {
|
||||
const el = preRef.current
|
||||
if (!el) return
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40
|
||||
setAutoScroll(atBottom)
|
||||
}
|
||||
|
||||
const btn =
|
||||
"p-1 rounded text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-white/10 transition-colors"
|
||||
@@ -76,8 +91,16 @@ export const LogViewer = forwardRef<LogViewerHandle, LogViewerProps>(function Lo
|
||||
{connected ? "streaming" : "disconnected"}
|
||||
</span>
|
||||
)}
|
||||
{follow && !autoScroll && <span className="text-amber-400">paused</span>}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => setAutoScroll((s) => !s)}
|
||||
className={btn}
|
||||
title={autoScroll ? "Pause auto-scroll" : "Resume auto-scroll (jump to latest)"}
|
||||
>
|
||||
{autoScroll ? <Pause size={13} /> : <Play size={13} />}
|
||||
</button>
|
||||
<button onClick={clear} className={btn} title="Clear">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
@@ -91,6 +114,8 @@ export const LogViewer = forwardRef<LogViewerHandle, LogViewerProps>(function Lo
|
||||
</span>
|
||||
</div>
|
||||
<pre
|
||||
ref={preRef}
|
||||
onScroll={onScroll}
|
||||
className={`p-3 text-xs font-mono text-gray-300 ${
|
||||
maximized ? "flex-1 overflow-auto" : "overflow-x-auto max-h-96 overflow-y-auto"
|
||||
}`}
|
||||
|
||||
Reference in New Issue
Block a user