mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
The timer registry hands every caller of a key the same origin, and every reasoning block in a turn was keyed `reasoning:<messageId>`. So the second and third blocks measured from the first one's start and each reported the running total as its own duration — the "6s, 6s, 16s" down a single turn. Key per block, and move the measurement into `useMeasuredDuration`, which keeps the number beside the origin that produced it. The thread virtualizes, so the component that watched a block finish is usually gone by the time anyone scrolls back to read it; component state forgot the duration on unmount and the row fell back to having none. A block that genuinely was never watched running — history from an earlier app session, or reasoning that arrived already complete — still has no duration to report, and now says "Thought" rather than sitting in the present tense at a turn that ended. Also drops the run summary's aggregate +N/−M: a run can no longer contain a file edit, so it was always zero. Each edit carries its own count on its card.
115 lines
3.8 KiB
TypeScript
115 lines
3.8 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
|
|
// Module-level registry so timers survive component unmount/remount (e.g.
|
|
// when a tool row scrolls out and back). Keyed by caller-supplied timerKey;
|
|
// anonymous timers (no key) start fresh each mount.
|
|
const startedAtByKey = new Map<string, number>()
|
|
|
|
// Durations of things that have already finished, kept beside the origins that
|
|
// measured them. See `useMeasuredDuration`.
|
|
const durationByKey = new Map<string, number>()
|
|
|
|
function startedAt(key?: string): number {
|
|
if (!key) {
|
|
return Date.now()
|
|
}
|
|
|
|
const existing = startedAtByKey.get(key)
|
|
|
|
if (existing !== undefined) {
|
|
return existing
|
|
}
|
|
|
|
const now = Date.now()
|
|
startedAtByKey.set(key, now)
|
|
|
|
return now
|
|
}
|
|
|
|
export function formatElapsed(seconds: number): string {
|
|
if (seconds < 60) {
|
|
return `${seconds}s`
|
|
}
|
|
|
|
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
|
}
|
|
|
|
/**
|
|
* Seconds since the timer's origin, reported once a second while `active`.
|
|
*
|
|
* Origin, in order: an explicit `since` timestamp, else the `timerKey`'s
|
|
* registry entry (survives unmount/remount), else mount time. Pass `since` when
|
|
* the thing being measured started at a moment the caller knows and that moment
|
|
* isn't the mount — otherwise an anonymous timer reports the component's age,
|
|
* which is only the same number by accident.
|
|
*/
|
|
export function useElapsedSeconds(active = true, timerKey?: string, since?: number): number {
|
|
const start = useRef(since ?? startedAt(timerKey))
|
|
const lastKey = useRef(timerKey)
|
|
const [elapsed, setElapsed] = useState(() => Math.max(0, Math.floor((Date.now() - start.current) / 1000)))
|
|
|
|
if (lastKey.current !== timerKey) {
|
|
start.current = since ?? startedAt(timerKey)
|
|
lastKey.current = timerKey
|
|
}
|
|
|
|
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
|
useEffect(() => {
|
|
if (!active) {
|
|
return
|
|
}
|
|
|
|
if (since !== undefined) {
|
|
start.current = since
|
|
} else if (timerKey) {
|
|
start.current = startedAt(timerKey)
|
|
}
|
|
|
|
const tick = () => setElapsed(Math.max(0, Math.floor((Date.now() - start.current) / 1000)))
|
|
tick()
|
|
const id = window.setInterval(tick, 1000)
|
|
|
|
return () => window.clearInterval(id)
|
|
}, [active, since, timerKey])
|
|
|
|
return elapsed
|
|
}
|
|
|
|
/**
|
|
* How long something took, measured by watching it finish and remembered
|
|
* afterwards. `null` until it has been watched at least once.
|
|
*
|
|
* Some durations exist nowhere but in the watching. A reasoning block is the
|
|
* case this was written for: the persisted turn records the text the model
|
|
* thought, never how long it spent thinking it, so the only way to know is to
|
|
* have been there. Watching alone isn't enough either — the thread virtualizes,
|
|
* so the component that saw a block finish is usually gone by the time anyone
|
|
* scrolls back to read it. Keeping the number in the same registry as the
|
|
* timer's origin lets it outlive the component that measured it.
|
|
*
|
|
* A block that was never watched running — history loaded from an earlier app
|
|
* session, or reasoning that arrived already complete — has no duration and
|
|
* says so, rather than reporting a timer that never ran.
|
|
*/
|
|
export function useMeasuredDuration(active: boolean, timerKey: string): null | number {
|
|
const elapsed = useElapsedSeconds(active, timerKey)
|
|
const [watching, setWatching] = useState(false)
|
|
const [measured, setMeasured] = useState<null | number>(() => durationByKey.get(timerKey) ?? null)
|
|
|
|
useEffect(() => {
|
|
if (active) {
|
|
setWatching(true)
|
|
} else if (watching) {
|
|
setWatching(false)
|
|
durationByKey.set(timerKey, elapsed)
|
|
setMeasured(elapsed)
|
|
}
|
|
}, [active, elapsed, timerKey, watching])
|
|
|
|
return measured
|
|
}
|
|
|
|
export function __resetElapsedTimerRegistryForTests() {
|
|
startedAtByKey.clear()
|
|
durationByKey.clear()
|
|
}
|