# NousResearch/hermes-agent · Stale compaction UI status fixes in desktop

## Open now

- 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.

## TL;DR

- 7 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 2 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #64127. Verify it addresses your case.
- **Recommended:** read PR #64127 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-64127,pr-122791`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Closed / duplicate issues

- #48098: Desktop can keep showing stale "Summarizing thread" status after compaction resumes
- #55775: Desktop UI: 'Summarizing thread' panel stuck and 'Running Running' task label duplication

## Prior & competing PRs

- **#64127: fix(desktop): clear stale compaction status across session switches**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
  - fix hunks:
  - `apps/desktop/src/app/session/hooks/use-message-stream/compaction-event.test.tsx`

    ```diff
    @@ -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`

    ```diff
    @@ -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>>
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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 '.'
    ```

    ```diff
    @@ -1075,6 +1075,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
     
       afterEach(() => {
         cleanup()
    +    $turnStartedAt.set(null)
         vi.restoreAllMocks()
       })
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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'
     
    ```

    ```diff
    @@ -501,6 +509,7 @@ export function usePromptActions({
         }
     
         setAwaitingResponse(false)
    +    setTurnStartedAt(null)
     
         const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) =>
           messages
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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()
     
    ```

    ```diff
    @@ -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 (
    ```

    ```diff
    @@ -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).
    ```

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
- **#48115: fix(desktop): clear compaction label on first content delta after mid-turn compaction**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `2d0f2185cf3bbf996128dfd5341eea1395b3aca7`, `#64127`
- **#48146: fix(desktop): clear compaction flag on \`message.delta\` to prevent stale "Summarizing thread" status**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `2d0f2185cf3bbf996128dfd5341eea1395b3aca7`, `#64127`
- **#55781: fix(desktop): clear stuck compaction overlay and de-dupe terminal tool-title verb (#55775)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#122791`, `#55775`

## Fix status

- Merged to main in PR #64127 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-09-25`.
- Subsystem: `apps/desktop`
