Stale compaction UI status fixes in desktop

NousResearch/hermes-agent · cluster 48098 · apps/desktop

Data as of

Nothing open on GitHub. Fix merged in #64127. Check it covers your case.

The short answer

0open issues
0open PRs
7items grouped
Known?
Yes. 7 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #64127. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PRs #64127, #122791. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and fixes address the desktop UI incorrectly showing a stuck 'Summarizing thread' overlay or duplicated task labels after compaction events. Multiple targeted fixes clear stale compaction state on message deltas, session switches, and mid-turn compaction scenarios.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #64127 fix(desktop): clear stale compaction status across session switches

    merged to main

    files: apps/desktop/src/app/session/hooks/use-message-stream/compaction-event.test.tsx, apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts, apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx, apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts, apps/desktop/src/components/assistant-ui/thread/status.test.tsx, apps/desktop/src/components/assistant-ui/thread/status.tsx

    Show the diff
    • apps/desktop/src/app/session/hooks/use-message-stream/compaction-event.test.tsx
      @@ -0,0 +1,82 @@
      +import { QueryClient } from '@tanstack/react-query'
      +import { act, cleanup, render, waitFor } from '@testing-library/react'
      +import { useEffect, useRef } from 'react'
      +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
      +
      +import type { ClientSessionState } from '@/app/types'
      +import { createClientSessionState } from '@/lib/chat-runtime'
      +import { $compactingSessions, setSessionCompacting } from '@/store/compaction'
      +import type { RpcEvent } from '@/types/hermes'
      +
      +import { useMessageStream } from './index'
      +
      +const SID = 'session-1'
      +const OTHER_SID = 'session-2'
      +let handleEvent: ((event: RpcEvent) => void) | null = null
      +
      +function Harness() {
      +  const activeSessionIdRef = useRef<string | null>(SID)
      +  const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
      +  const queryClientRef = useRef(new QueryClient())
      +
      +  const stream = useMessageStream({
      +    activeSessionIdRef,
      +    hydrateFromStoredSession: vi.fn(async () => undefined),
      +    queryClient: queryClientRef.current,
      +    refreshHermesConfig: vi.fn(async () => undefined),
      +    refreshSessions: vi.fn(async () => undefined),
      +    sessionStateByRuntimeIdRef,
      +    updateSessionState: (sessionId, updater) => {
      +      const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
      +      const next = updater(current)
      +      sessionStateByRuntimeIdRef.current.set(sessionId, next)
      +
      +      return next
      +    }
      +  })
      +
      +  useEffect(() => {
      +    handleEvent = stream.handleGatewayEvent
      +  }, [stream.handleGatewayEvent])
      +
      +  return null
      +}
      +
      +async function mountStream() {
      +  render(<Harness />)
      +  await waitFor(() => expect(handleEvent).not.toBeNull())
      +}
      +
      +function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}) {
      +  act(() => handleEvent!({ payload, session_id: SID, type }))
      +}
      +
      +describe('useMessageStream compaction lifecycle', () => {
      +  beforeEach(() => {
      +    handleEvent = null
      +    $compactingSessions.set({})
      +  })
      +
      +  afterEach(() => {
      +    cleanup()
      +    $compactingSessions.set({})
      +    vi.restoreAllMocks()
      +  })
      +
      +  it.each([
      +    ['message.delta', { text: 'resumed' }],
      +    ['thinking.delta', { text: 'still working' }],
      +    ['reasoning.delta', { text: 'thinking again' }],
      +    ['tool.start', { name: 'terminal', tool_id: 'tool-1' }]
      +  ] as const)('clears the stale compaction phase when %s resumes the turn', async (type, payload) => {
      +    await mountStream()
      +    setSessionCompacting(OTHER_SID, true)
      +
      +    emit('status.update', { kind: 'compacting' })
      +    expect($compactingSessions.get()).toEqual({ [OTHER_SID]: true, [SID]: true })
      +
      +    emit(type, payload)
      +
      +    expect($compactingSessions.get()).toEqual({ [OTHER_SID]: true })
      +  })
      +})
    • apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts
      @@ -50,6 +50,19 @@ import type { ClientSessionState } from '../../../types'
       
       import { hasSessionInfoStatePatch, sessionInfoStatePatch, SUBAGENT_EVENT_TYPES, toTodoPayload } from './utils'
       
      +const COMPACTION_RESUME_EVENT_TYPES = new Set([
      +  'message.delta',
      +  'thinking.delta',
      +  'reasoning.delta',
      +  'reasoning.available',
      +  'moa.reference',
      +  'moa.aggregating',
      +  'tool.start',
      +  'tool.progress',
      +  'tool.generating',
      +  'tool.complete'
      +])
      +
       interface GatewayEventDeps {
         activeSessionIdRef: MutableRefObject<string | null>
         compactedTurnRef: MutableRefObject<Set<string>>
      @@ -118,6 +131,18 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
             const sessionId = route.sessionId
             const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current
       
      +      // Mid-turn compaction does not emit another message.start. The first
      +      // model output or tool event proves summarization has finished and the
      +      // turn has resumed, so retire the phase label without waiting for the
      +      // whole turn to complete.
      +      if (
      +        sessionId &&
      +        COMPACTION_RESUME_EVENT_TYPES.has(event.type) &&
      +        compactedTurnRef.current.has(sessionId)
      +      ) {
      +        setSessionCompacting(sessionId, false)
      +      }
      +
             if (event.type === 'gateway.ready') {
               return
             } else if (event.type === 'session.info') {
    • apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx
      @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
       
       import { textPart } from '@/lib/chat-messages'
       import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer'
      -import { $busy, $connection, $messages, $sessions, setSessions } from '@/store/session'
      +import { $busy, $connection, $messages, $sessions, $turnStartedAt, setSessions } from '@/store/session'
       import type { SessionInfo } from '@/types/hermes'
       
       import { uploadComposerAttachment, usePromptActions } from '.'
      @@ -1075,6 +1075,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
       
         afterEach(() => {
           cleanup()
      +    $turnStartedAt.set(null)
           vi.restoreAllMocks()
         })
       
      @@ -1168,6 +1169,32 @@ describe('usePromptActions sleep/wake session recovery', () => {
           expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID })
         })
       
      +  it('clears the active and cached turn clocks when stopping a turn', async () => {
      +    const states: Record<string, unknown>[] = []
      +    const requestGateway = vi.fn(async () => ({}) as never)
      +    $turnStartedAt.set(1_700_000_000_000)
      +
      +    let handle: HarnessHandle | null = null
      +    await actRender(
      +      <Harness
      +        onReady={h => (handle = h)}
      +        onSeedState={state => states.push(state)}
      +        refreshSessions={async () => undefined}
      +        requestGateway={requestGateway}
      +      />
      +    )
      +
      +    await handle!.cancelRun()
      +
      +    expect($turnStartedAt.get()).toBeNull()
      +    expect(states.at(-1)).toMatchObject({
      +      awaitingResponse: false,
      +      busy: false,
      +      interrupted: true,
      +      turnStartedAt: null
      +    })
      +  })
      +
         it('surfaces the original error (no resume) when the failure is not "session not found"', async () => {
           const calls: string[] = []
           const states: Record<string, unknown>[] = []
    • apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts
      @@ -21,7 +21,15 @@ import { resetSessionBackground } from '@/store/composer-status'
       import { clearNotifications, notify, notifyError } from '@/store/notifications'
       import { clearPreviewArtifacts } from '@/store/preview-status'
       import { clearAllPrompts } from '@/store/prompts'
      -import { $busy, $connection, $messages, setAwaitingResponse, setBusy, setMessages } from '@/store/session'
      +import {
      +  $busy,
      +  $connection,
      +  $messages,
      +  setAwaitingResponse,
      +  setBusy,
      +  setMessages,
      +  setTurnStartedAt
      +} from '@/store/session'
       import { clearSessionSubagents } from '@/store/subagents'
       import { clearSessionTodos } from '@/store/todos'
       
      @@ -501,6 +509,7 @@ export function usePromptActions({
           }
       
           setAwaitingResponse(false)
      +    setTurnStartedAt(null)
       
           const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) =>
             messages
      @@ -526,7 +535,8 @@ export function usePromptActions({
               streamId: null,
               pendingBranchGroup: null,
               needsInput: false,
      -        interrupted: true
      +        interrupted: true,
      +        turnStartedAt: null
             }
           })
       
    • apps/desktop/src/components/assistant-ui/thread/status.test.tsx
      @@ -0,0 +1,56 @@
      +import { act, cleanup, render, screen } from '@testing-library/react'
      +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
      +
      +import { __resetElapsedTimerRegistryForTests } from '@/components/chat/activity-timer'
      +import { I18nProvider } from '@/i18n'
      +import { $activeSessionId, $turnStartedAt } from '@/store/session'
      +
      +import { ResponseLoadingIndicator } from './status'
      +
      +function renderIndicator() {
      +  return render(
      +    <I18nProvider configClient={null} initialLocale="en">
      +      <ResponseLoadingIndicator />
      +    </I18nProvider>
      +  )
      +}
      +
      +describe('ResponseLoadingIndicator timer', () => {
      +  beforeEach(() => {
      +    vi.useFakeTimers()
      +    vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
      +    __resetElapsedTimerRegistryForTests()
      +  })
      +
      +  afterEach(() => {
      +    cleanup()
      +    $activeSessionId.set(null)
      +    $turnStartedAt.set(null)
      +    __resetElapsedTimerRegistryForTests()
      +    vi.useRealTimers()
      +  })
      +
      +  it('preserves each running session timer while switching between sessions', () => {
      +    $activeSessionId.set('session-a')
      +    $turnStartedAt.set(Date.now())
      +    const sessionA = renderIndicator()
      +
      +    act(() => vi.advanceTimersByTime(5_000))
      +    expect(screen.getByText('5s')).toBeTruthy()
      +    sessionA.unmount()
      +
      +    $activeSessionId.set('session-b')
      +    $turnStartedAt.set(Date.now())
      +    const sessionB = renderIndicator()
      +
      +    act(() => vi.advanceTimersByTime(3_000))
      +    expect(screen.getByText('3s')).toBeTruthy()
      +    sessionB.unmount()
      +
      +    $activeSessionId.set('session-a')
      +    $turnStartedAt.set(new Date('2026-01-01T00:00:00.000Z').getTime())
      +    renderIndicator()
      +
      +    expect(screen.getByText('8s')).toBeTruthy()
      +  })
      +})
    • apps/desktop/src/components/assistant-ui/thread/status.tsx
      @@ -11,6 +11,7 @@ import { cn } from '@/lib/utils'
       import { $backgroundResume } from '@/store/background-delegation'
       import { $compactionActive } from '@/store/compaction'
       import { $activeSessionAwaitingInput } from '@/store/prompts'
      +import { $activeSessionId, $turnStartedAt } from '@/store/session'
       
       const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({
         children,
      @@ -36,6 +37,13 @@ const CompactionHint: FC = () => (
         <span className="shimmer min-w-0 truncate text-muted-foreground/55">{COMPACTION_LABEL}</span>
       )
       
      +function useActiveTurnTimerKey(): string | undefined {
      +  const activeSessionId = useStore($activeSessionId)
      +  const turnStartedAt = useStore($turnStartedAt)
      +
      +  return activeSessionId && turnStartedAt ? `turn:${activeSessionId}:${turnStartedAt}` : undefined
      +}
      +
       export const CenteredThreadSpinner: FC = () => {
         const { t } = useI18n()
       
      @@ -59,7 +67,8 @@ export const CenteredThreadSpinner: FC = () => {
       
       export const ResponseLoadingIndicator: FC = () => {
         const { t } = useI18n()
      -  const elapsed = useElapsedSeconds()
      +  const timerKey = useActiveTurnTimerKey()
      +  const elapsed = useElapsedSeconds(true, timerKey)
         const compacting = useStore($compactionActive)
       
         return (
      @@ -134,6 +143,7 @@ export const StreamStallIndicator: FC = () => {
       
         const [stalled, setStalled] = useState(false)
         const compacting = useStore($compactionActive)
      +  const turnTimerKey = useActiveTurnTimerKey()
         // A pending clarify / approval / sudo / secret means the turn is paused on the
         // user, not working — so don't resurrect the "thinking" timer while they
         // decide (matches the pet's awaitingInput pose taking priority over busy).
      @@ -147,7 +157,7 @@ export const StreamStallIndicator: FC = () => {
         }, [activity])
       
         const active = (stalled || compacting) && !awaitingInput
      -  const elapsed = useElapsedSeconds(active)
      +  const elapsed = useElapsedSeconds(active, compacting ? turnTimerKey : undefined)
       
         if (!active) {
           return null
  • #122791 fix(desktop): clear compaction overlay on Stop and de-duplicate tool title verbs

    merged to main

    files: apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx, apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts, apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts, apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts

3 PRs closed without merging

From the close comments: superseded by another PR 2 · done elsewhere 1

  • #48115 fix(desktop): clear compaction label on first content delta after mid-turn compaction · superseded by another PR → 2d0f218, #64127
  • #48146 fix(desktop): clear compaction flag on `message.delta` to prevent stale "Summarizing thread" status · done elsewhere → 2d0f218, #64127
  • #55781 fix(desktop): clear stuck compaction overlay and de-dupe terminal tool-title verb (#55775) · superseded by another PR → #122791, #55775
Closed issues · 2
  • Closed#48098Desktop can keep showing stale "Summarizing thread" status after compaction resumes
  • Closed#55775Desktop UI: 'Summarizing thread' panel stuck and 'Running Running' task label duplication
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.26: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#64127, #122791
Closed issues
2
Attempts
3 PRs closed without merging
Fix link checked
25 Sep 2026

For your agent

The same facts, typed.