diff --git a/src/renderer/components/runs/RunCenter.tsx b/src/renderer/components/runs/RunCenter.tsx index 86018f53..4339447f 100644 --- a/src/renderer/components/runs/RunCenter.tsx +++ b/src/renderer/components/runs/RunCenter.tsx @@ -2,10 +2,9 @@ import { useEffect, useMemo, useState } from "react"; import type { AgentRecord, RunRecord, TaskRecord, WorkspaceRecord } from "@shared/types"; import { EmptyState } from "../ui"; import { useT } from "../../i18n"; -import { LOG_CHUNK_BYTES, initialLogState, isActiveStatus } from "./run-helpers"; -import type { RunLogState } from "./run-helpers"; import { RunQueue } from "./RunQueue"; import { RunDetail } from "./RunDetail"; +import { useRunLogs } from "../../hooks/useRunLogs"; interface RunCenterProps { runs: RunRecord[]; @@ -33,8 +32,6 @@ export function RunCenter({ const t = useT(); const [statusFilter, setStatusFilter] = useState<"all" | RunRecord["status"]>("all"); const [search, setSearch] = useState(""); - const [logState, setLogState] = useState(initialLogState); - const [logReloadKey, setLogReloadKey] = useState(0); const agentMap = useMemo(() => new Map(agents.map((agent) => [agent.id, agent])), [agents]); const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]); @@ -69,6 +66,8 @@ export function RunCenter({ }); }, [agentMap, runs, search, statusFilter, taskMap, workspaceMap]); + const { logState, reload: reloadLogs, loadOlder: loadOlderLogs } = useRunLogs(selectedRun); + useEffect(() => { if (selectedRunId && !runs.some((run) => run.id === selectedRunId)) { const fallbackRun = runs[0]; @@ -78,138 +77,6 @@ export function RunCenter({ } }, [onSelectRun, runs, selectedRunId]); - useEffect(() => { - let cancelled = false; - - async function loadInitialChunk() { - if (!selectedRun) { - setLogState(initialLogState); - return; - } - - setLogState((current) => ({ ...current, loading: true, error: null })); - - const metaResult = await window.agentCompany.getRunLogChunk({ - companyId: selectedRun.companyId, - runId: selectedRun.id, - offset: 0, - limit: 1024, - }); - - if (!metaResult.ok) { - if (!cancelled) { - setLogState((current) => ({ - ...current, - loading: false, - error: metaResult.error.message, - })); - } - return; - } - - const totalBytes = metaResult.data.totalBytes; - const startOffset = Math.max(0, totalBytes - LOG_CHUNK_BYTES); - const chunkResult = - startOffset === 0 && totalBytes <= LOG_CHUNK_BYTES - ? metaResult - : await window.agentCompany.getRunLogChunk({ - companyId: selectedRun.companyId, - runId: selectedRun.id, - offset: startOffset, - limit: LOG_CHUNK_BYTES, - }); - - if (!chunkResult.ok) { - if (!cancelled) { - setLogState((current) => ({ - ...current, - loading: false, - error: chunkResult.error.message, - })); - } - return; - } - - if (!cancelled) { - setLogState({ - ...chunkResult.data, - loading: false, - error: null, - }); - } - } - - void loadInitialChunk(); - - return () => { - cancelled = true; - }; - }, [logReloadKey, selectedRun]); - - useEffect(() => { - if (!selectedRun || !isActiveStatus(selectedRun.status)) return; - let cancelled = false; - const tick = async () => { - if (document.visibilityState === "hidden") return; - const latest = await window.agentCompany.getRunLogChunk({ - companyId: selectedRun.companyId, - runId: selectedRun.id, - offset: Math.max(0, logState.totalBytes - LOG_CHUNK_BYTES), - limit: LOG_CHUNK_BYTES, - }); - - if (!latest.ok || cancelled) { - return; - } - - setLogState({ - ...latest.data, - loading: false, - error: null, - }); - }; - const intervalId = window.setInterval(tick, 2500); - - return () => { - cancelled = true; - window.clearInterval(intervalId); - }; - }, [logState.totalBytes, selectedRun]); - - async function loadOlderLogs() { - if (!selectedRun || logState.offset <= 0) { - return; - } - - const nextOffset = Math.max(0, logState.offset - LOG_CHUNK_BYTES); - setLogState((current) => ({ ...current, loading: true, error: null })); - - const result = await window.agentCompany.getRunLogChunk({ - companyId: selectedRun.companyId, - runId: selectedRun.id, - offset: nextOffset, - limit: logState.offset - nextOffset, - }); - - if (!result.ok) { - setLogState((current) => ({ - ...current, - loading: false, - error: result.error.message, - })); - return; - } - - setLogState({ - ...result.data, - content: `${result.data.content}${logState.content}`, - nextOffset: logState.nextOffset, - eof: logState.eof, - loading: false, - error: null, - }); - } - if (runs.length === 0) { return ( setLogReloadKey((current) => current + 1)} + onReloadLog={reloadLogs} onLoadOlderLogs={() => void loadOlderLogs()} t={t} /> diff --git a/src/renderer/hooks/useRunLogs.ts b/src/renderer/hooks/useRunLogs.ts new file mode 100644 index 00000000..f79a0ee8 --- /dev/null +++ b/src/renderer/hooks/useRunLogs.ts @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { RunRecord } from "@shared/types"; +import { LOG_CHUNK_BYTES, initialLogState, isActiveStatus } from "../components/runs/run-helpers"; +import type { RunLogState } from "../components/runs/run-helpers"; + +const POLL_INTERVAL_MS = 2500; + +export interface UseRunLogsResult { + logState: RunLogState; + reload: () => void; + loadOlder: () => Promise; +} + +/** + * Owns log loading, tailing, and older-chunk fetching for a single run. + * + * One effect handles both the initial tail fetch and periodic polling. + * An AbortController scopes every in-flight request to the current run + * selection so switching runs immediately discards stale responses, and + * polling pauses when `document.hidden` to avoid wasting IPC calls. + */ +export function useRunLogs(selectedRun: RunRecord | null): UseRunLogsResult { + const [logState, setLogState] = useState(initialLogState); + const [reloadKey, setReloadKey] = useState(0); + const totalBytesRef = useRef(0); + + useEffect(() => { + totalBytesRef.current = logState.totalBytes; + }, [logState.totalBytes]); + + const reload = useCallback(() => { + setReloadKey((k) => k + 1); + }, []); + + useEffect(() => { + if (!selectedRun) { + setLogState(initialLogState); + return; + } + + const controller = new AbortController(); + let intervalId: number | null = null; + + async function fetchTail(initial: boolean) { + if (!selectedRun) return; + if (!initial && document.hidden) return; + + if (initial) { + setLogState((current) => ({ ...current, loading: true, error: null })); + } + + const metaResult = initial + ? await window.agentCompany.getRunLogChunk({ + companyId: selectedRun.companyId, + runId: selectedRun.id, + offset: 0, + limit: 1024, + }) + : null; + + if (controller.signal.aborted) return; + + let totalBytes: number; + if (initial) { + if (!metaResult!.ok) { + setLogState((current) => ({ ...current, loading: false, error: metaResult!.error.message })); + return; + } + totalBytes = metaResult!.data.totalBytes; + } else { + totalBytes = totalBytesRef.current; + } + + const startOffset = Math.max(0, totalBytes - LOG_CHUNK_BYTES); + const tailResult = + initial && startOffset === 0 && totalBytes <= LOG_CHUNK_BYTES + ? metaResult! + : await window.agentCompany.getRunLogChunk({ + companyId: selectedRun.companyId, + runId: selectedRun.id, + offset: startOffset, + limit: LOG_CHUNK_BYTES, + }); + + if (controller.signal.aborted) return; + if (!tailResult.ok) { + if (initial) { + setLogState((current) => ({ ...current, loading: false, error: tailResult.error.message })); + } + return; + } + + setLogState({ ...tailResult.data, loading: false, error: null }); + } + + void fetchTail(true); + + if (isActiveStatus(selectedRun.status)) { + intervalId = window.setInterval(() => { + void fetchTail(false); + }, POLL_INTERVAL_MS); + } + + return () => { + controller.abort(); + if (intervalId !== null) window.clearInterval(intervalId); + }; + }, [selectedRun, reloadKey]); + + const loadOlder = useCallback(async () => { + if (!selectedRun || logState.offset <= 0) return; + + const nextOffset = Math.max(0, logState.offset - LOG_CHUNK_BYTES); + setLogState((current) => ({ ...current, loading: true, error: null })); + + const result = await window.agentCompany.getRunLogChunk({ + companyId: selectedRun.companyId, + runId: selectedRun.id, + offset: nextOffset, + limit: logState.offset - nextOffset, + }); + + if (!result.ok) { + setLogState((current) => ({ ...current, loading: false, error: result.error.message })); + return; + } + + setLogState((current) => ({ + ...result.data, + content: `${result.data.content}${current.content}`, + nextOffset: current.nextOffset, + eof: current.eof, + loading: false, + error: null, + })); + }, [selectedRun, logState.offset]); + + return { logState, reload, loadOlder }; +}