hermes-agent / apps /desktop /src /components /chat /activity-timer.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
a1b6014 verified
Raw History Blame Contribute Delete
4.07 kB
import { useEffect, useRef, useState } from 'react'
import { useViewedInterval } from '@/hooks/use-viewed-interval'
// 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 -- timer origin is imperative state, not an atom mirror
useEffect(() => {
if (since !== undefined) {
start.current = since
} else if (timerKey) {
start.current = startedAt(timerKey)
}
if (active) {
setElapsed(Math.max(0, Math.floor((Date.now() - start.current) / 1000)))
}
}, [active, since, timerKey])
useViewedInterval(() => setElapsed(Math.max(0, Math.floor((Date.now() - start.current) / 1000))), 1000, active)
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) {
const finalElapsed = Math.max(elapsed, Math.floor((Date.now() - startedAt(timerKey)) / 1000))
setWatching(false)
durationByKey.set(timerKey, finalElapsed)
setMeasured(finalElapsed)
}
}, [active, elapsed, timerKey, watching])
return measured
}
export function __resetElapsedTimerRegistryForTests() {
startedAtByKey.clear()
durationByKey.clear()
}