mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(desktop): time a stream stall from the last activity
The tail "Hermes is thinking" indicator resets on every flush, but its timer never did: with no timer key, useElapsedSeconds anchors to mount, and the indicator mounts with the assistant message. A stall two minutes into a turn therefore claimed two minutes of silence instead of the two seconds that had actually passed. Give the hook an explicit epoch and hand it the timestamp of the activity the quiet spell followed. Compaction still counts from the turn's start, which is the span it owns.
This commit is contained in:
parent
e9bb4c3951
commit
2f5926ed05
3 changed files with 57 additions and 12 deletions
|
|
@ -142,7 +142,11 @@ export const StreamStallIndicator: FC = () => {
|
|||
return `${s.message.content.length}:${textLength}`
|
||||
})
|
||||
|
||||
const [stalled, setStalled] = useState(false)
|
||||
// Timestamp of the activity that preceded the current quiet spell, set once
|
||||
// the spell qualifies as a stall. Holding the timestamp (not a boolean) is
|
||||
// what lets the timer read "quiet for 12s" rather than the age of this
|
||||
// component, which is the whole turn so far.
|
||||
const [quietSince, setQuietSince] = useState<number | undefined>(undefined)
|
||||
const compacting = useStore($compactionActive)
|
||||
const turnTimerKey = useActiveTurnTimerKey()
|
||||
// A pending clarify / approval / sudo / secret means the turn is paused on the
|
||||
|
|
@ -151,14 +155,18 @@ export const StreamStallIndicator: FC = () => {
|
|||
const awaitingInput = useStore($activeSessionAwaitingInput)
|
||||
|
||||
useEffect(() => {
|
||||
setStalled(false)
|
||||
const id = window.setTimeout(() => setStalled(true), STREAM_STALL_S * 1000)
|
||||
setQuietSince(undefined)
|
||||
const seenAt = Date.now()
|
||||
const id = window.setTimeout(() => setQuietSince(seenAt), STREAM_STALL_S * 1000)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}, [activity])
|
||||
|
||||
const active = (stalled || compacting) && !awaitingInput
|
||||
const elapsed = useElapsedSeconds(active, compacting ? turnTimerKey : undefined)
|
||||
const active = (quietSince !== undefined || compacting) && !awaitingInput
|
||||
|
||||
// Compaction owns the whole turn, so it keeps counting from the turn's start;
|
||||
// a plain stall counts from the last thing the stream produced.
|
||||
const elapsed = useElapsedSeconds(active, compacting ? turnTimerKey : undefined, compacting ? undefined : quietSince)
|
||||
|
||||
if (!active) {
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
|
||||
import { __resetElapsedTimerRegistryForTests, useElapsedSeconds } from './activity-timer'
|
||||
|
||||
function Probe({ active, timerKey }: { active: boolean; timerKey?: string }) {
|
||||
const elapsed = useElapsedSeconds(active, timerKey)
|
||||
function Probe({ active, since, timerKey }: { active: boolean; since?: number; timerKey?: string }) {
|
||||
const elapsed = useElapsedSeconds(active, timerKey, since)
|
||||
|
||||
return <span data-testid="elapsed">{elapsed}</span>
|
||||
}
|
||||
|
|
@ -40,4 +40,30 @@ describe('useElapsedSeconds', () => {
|
|||
|
||||
expect(screen.getByTestId('elapsed').textContent).toBe('8')
|
||||
})
|
||||
|
||||
it('counts from an explicit epoch rather than mount time', () => {
|
||||
const mountedAt = Date.now()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30_000)
|
||||
})
|
||||
|
||||
render(<Probe active since={mountedAt + 28_000} />)
|
||||
|
||||
expect(screen.getByTestId('elapsed').textContent).toBe('2')
|
||||
})
|
||||
|
||||
it('re-anchors when the epoch moves', () => {
|
||||
const { rerender } = render(<Probe active since={Date.now()} />)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10_000)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('elapsed').textContent).toBe('10')
|
||||
|
||||
rerender(<Probe active since={Date.now()} />)
|
||||
|
||||
expect(screen.getByTestId('elapsed').textContent).toBe('0')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -30,13 +30,22 @@ export function formatElapsed(seconds: number): string {
|
|||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function useElapsedSeconds(active = true, timerKey?: string): number {
|
||||
const start = useRef(startedAt(timerKey))
|
||||
/**
|
||||
* 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 = startedAt(timerKey)
|
||||
start.current = since ?? startedAt(timerKey)
|
||||
lastKey.current = timerKey
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +55,9 @@ export function useElapsedSeconds(active = true, timerKey?: string): number {
|
|||
return
|
||||
}
|
||||
|
||||
if (timerKey) {
|
||||
if (since !== undefined) {
|
||||
start.current = since
|
||||
} else if (timerKey) {
|
||||
start.current = startedAt(timerKey)
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +66,7 @@ export function useElapsedSeconds(active = true, timerKey?: string): number {
|
|||
const id = window.setInterval(tick, 1000)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [active, timerKey])
|
||||
}, [active, since, timerKey])
|
||||
|
||||
return elapsed
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue