fix(web): render ansi logs with wrapped lines (#1425)

This commit is contained in:
wenjie
2026-03-12 19:12:19 +08:00
committed by GitHub
parent 7872bb3f0a
commit d18a319b0c
13 changed files with 595 additions and 159 deletions
@@ -0,0 +1,96 @@
import { useAtomValue } from "jotai"
import { useEffect, useRef, useState } from "react"
import { clearGatewayLogs, getGatewayStatus } from "@/api/gateway"
import { gatewayAtom } from "@/store/gateway"
export function useGatewayLogs() {
const [logs, setLogs] = useState<string[]>([])
const [clearing, setClearing] = useState(false)
const logOffsetRef = useRef(0)
const logRunIdRef = useRef(-1)
const syncTokenRef = useRef(0)
const gateway = useAtomValue(gatewayAtom)
const clearLogs = async () => {
setClearing(true)
try {
const data = await clearGatewayLogs()
syncTokenRef.current += 1
setLogs([])
logOffsetRef.current = data.log_total ?? 0
if (data.log_run_id !== undefined) {
logRunIdRef.current = data.log_run_id
}
} catch {
// Ignore clear failures silently to avoid noisy transient errors.
} finally {
setClearing(false)
}
}
useEffect(() => {
let mounted = true
let timeout: ReturnType<typeof setTimeout>
const fetchLogs = async () => {
if (
!mounted ||
(gateway.status !== "running" && gateway.status !== "starting")
) {
if (mounted) {
timeout = setTimeout(fetchLogs, 1000)
}
return
}
try {
const requestToken = syncTokenRef.current
const requestOffset = logOffsetRef.current
const requestRunId = logRunIdRef.current
const data = await getGatewayStatus({
log_offset: requestOffset,
log_run_id: requestRunId,
})
if (!mounted || requestToken !== syncTokenRef.current) {
return
}
if (data.log_run_id !== undefined && data.log_run_id !== requestRunId) {
logRunIdRef.current = data.log_run_id
logOffsetRef.current = 0
if (data.logs) {
setLogs(data.logs)
logOffsetRef.current = data.log_total || data.logs.length
}
} else if (data.logs && data.logs.length > 0) {
const nextLogs = data.logs
setLogs((prev) => [...prev, ...nextLogs])
logOffsetRef.current =
data.log_total || logOffsetRef.current + nextLogs.length
}
} catch {
// Ignore simple fetch errors during polling.
} finally {
if (mounted) {
timeout = setTimeout(fetchLogs, 1000)
}
}
}
fetchLogs()
return () => {
mounted = false
clearTimeout(timeout)
}
}, [gateway.status])
return {
clearLogs,
clearing,
logs,
}
}
@@ -0,0 +1,52 @@
import { useEffect, useRef, useState } from "react"
const DEFAULT_WRAP_COLUMNS = 120
const MIN_WRAP_COLUMNS = 20
export function useLogWrapColumns() {
const [wrapColumns, setWrapColumns] = useState(DEFAULT_WRAP_COLUMNS)
const contentRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLSpanElement>(null)
useEffect(() => {
const content = contentRef.current
const measure = measureRef.current
if (!content || !measure) {
return
}
const updateWrapColumns = () => {
const contentWidth = content.clientWidth
const charWidth = measure.getBoundingClientRect().width
if (!contentWidth || !charWidth) {
return
}
const nextColumns = Math.max(
Math.floor(contentWidth / charWidth) - 1,
MIN_WRAP_COLUMNS,
)
setWrapColumns((current) =>
current === nextColumns ? current : nextColumns,
)
}
updateWrapColumns()
const observer = new ResizeObserver(updateWrapColumns)
observer.observe(content)
return () => {
observer.disconnect()
}
}, [])
return {
contentRef,
measureRef,
wrapColumns,
}
}