{"repo":"NousResearch/hermes-agent","public_id":"48098","display_label":"Stale compaction UI status fixes in desktop","summary":"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.","subsystem":"apps/desktop","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":64127},"resolution":{"state":"resolved","by":"pr-64127,pr-122791"},"freshness":{"state":"verified","last_verified_at":"2026-09-25T22:03:36+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2552380952380952},"fix_edges":[{"issue_number":48098,"pr_number":64127,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":55775,"pr_number":122791,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":62158,"pr_number":64127,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":48098,"title":"Desktop can keep showing stale \"Summarizing thread\" status after compaction resumes","url":null,"state":"closed"},{"kind":"issue","number":55775,"title":"Desktop UI: 'Summarizing thread' panel stuck and 'Running Running' task label duplication","url":null,"state":"closed"},{"kind":"pr","number":48115,"title":"fix(desktop): clear compaction label on first content delta after mid-turn compaction","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"2d0f2185cf3bbf996128dfd5341eea1395b3aca7","number":0},{"kind":"pr","value":"#64127","number":64127}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":48146,"title":"fix(desktop): clear compaction flag on `message.delta` to prevent stale \"Summarizing thread\" status","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"2d0f2185cf3bbf996128dfd5341eea1395b3aca7","number":0},{"kind":"pr","value":"#64127","number":64127}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":55781,"title":"fix(desktop): clear stuck compaction overlay and de-dupe terminal tool-title verb (#55775)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#122791","number":122791},{"kind":"pr","value":"#55775","number":55775}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":64127,"title":"fix(desktop): clear stale compaction status across session switches","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"apps/desktop/src/app/session/hooks/use-message-stream/compaction-event.test.tsx","hunks":["@@ -0,0 +1,82 @@\n+import { QueryClient } from '@tanstack/react-query'\n+import { act, cleanup, render, waitFor } from '@testing-library/react'\n+import { useEffect, useRef } from 'react'\n+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n+\n+import type { ClientSessionState } from '@/app/types'\n+import { createClientSessionState } from '@/lib/chat-runtime'\n+import { $compactingSessions, setSessionCompacting } from '@/store/compaction'\n+import type { RpcEvent } from '@/types/hermes'\n+\n+import { useMessageStream } from './index'\n+\n+const SID = 'session-1'\n+const OTHER_SID = 'session-2'\n+let handleEvent: ((event: RpcEvent) => void) | null = null\n+\n+function Harness() {\n+  const activeSessionIdRef = useRef<string | null>(SID)\n+  const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())\n+  const queryClientRef = useRef(new QueryClient())\n+\n+  const stream = useMessageStream({\n+    activeSessionIdRef,\n+    hydrateFromStoredSession: vi.fn(async () => undefined),\n+    queryClient: queryClientRef.current,\n+    refreshHermesConfig: vi.fn(async () => undefined),\n+    refreshSessions: vi.fn(async () => undefined),\n+    sessionStateByRuntimeIdRef,\n+    updateSessionState: (sessionId, updater) => {\n+      const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()\n+      const next = updater(current)\n+      sessionStateByRuntimeIdRef.current.set(sessionId, next)\n+\n+      return next\n+    }\n+  })\n+\n+  useEffect(() => {\n+    handleEvent = stream.handleGatewayEvent\n+  }, [stream.handleGatewayEvent])\n+\n+  return null\n+}\n+\n+async function mountStream() {\n+  render(<Harness />)\n+  await waitFor(() => expect(handleEvent).not.toBeNull())\n+}\n+\n+function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}) {\n+  act(() => handleEvent!({ payload, session_id: SID, type }))\n+}\n+\n+describe('useMessageStream compaction lifecycle', () => {\n+  beforeEach(() => {\n+    handleEvent = null\n+    $compactingSessions.set({})\n+  })\n+\n+  afterEach(() => {\n+    cleanup()\n+    $compactingSessions.set({})\n+    vi.restoreAllMocks()\n+  })\n+\n+  it.each([\n+    ['message.delta', { text: 'resumed' }],\n+    ['thinking.delta', { text: 'still working' }],\n+    ['reasoning.delta', { text: 'thinking again' }],\n+    ['tool.start', { name: 'terminal', tool_id: 'tool-1' }]\n+  ] as const)('clears the stale compaction phase when %s resumes the turn', async (type, payload) => {\n+    await mountStream()\n+    setSessionCompacting(OTHER_SID, true)\n+\n+    emit('status.update', { kind: 'compacting' })\n+    expect($compactingSessions.get()).toEqual({ [OTHER_SID]: true, [SID]: true })\n+\n+    emit(type, payload)\n+\n+    expect($compactingSessions.get()).toEqual({ [OTHER_SID]: true })\n+  })\n+})"]},{"file":"apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts","hunks":["@@ -50,6 +50,19 @@ import type { ClientSessionState } from '../../../types'\n \n import { hasSessionInfoStatePatch, sessionInfoStatePatch, SUBAGENT_EVENT_TYPES, toTodoPayload } from './utils'\n \n+const COMPACTION_RESUME_EVENT_TYPES = new Set([\n+  'message.delta',\n+  'thinking.delta',\n+  'reasoning.delta',\n+  'reasoning.available',\n+  'moa.reference',\n+  'moa.aggregating',\n+  'tool.start',\n+  'tool.progress',\n+  'tool.generating',\n+  'tool.complete'\n+])\n+\n interface GatewayEventDeps {\n   activeSessionIdRef: MutableRefObject<string | null>\n   compactedTurnRef: MutableRefObject<Set<string>>","@@ -118,6 +131,18 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {\n       const sessionId = route.sessionId\n       const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current\n \n+      // Mid-turn compaction does not emit another message.start. The first\n+      // model output or tool event proves summarization has finished and the\n+      // turn has resumed, so retire the phase label without waiting for the\n+      // whole turn to complete.\n+      if (\n+        sessionId &&\n+        COMPACTION_RESUME_EVENT_TYPES.has(event.type) &&\n+        compactedTurnRef.current.has(sessionId)\n+      ) {\n+        setSessionCompacting(sessionId, false)\n+      }\n+\n       if (event.type === 'gateway.ready') {\n         return\n       } else if (event.type === 'session.info') {"]},{"file":"apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx","hunks":["@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n \n import { textPart } from '@/lib/chat-messages'\n import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer'\n-import { $busy, $connection, $messages, $sessions, setSessions } from '@/store/session'\n+import { $busy, $connection, $messages, $sessions, $turnStartedAt, setSessions } from '@/store/session'\n import type { SessionInfo } from '@/types/hermes'\n \n import { uploadComposerAttachment, usePromptActions } from '.'","@@ -1075,6 +1075,7 @@ describe('usePromptActions sleep/wake session recovery', () => {\n \n   afterEach(() => {\n     cleanup()\n+    $turnStartedAt.set(null)\n     vi.restoreAllMocks()\n   })\n ","@@ -1168,6 +1169,32 @@ describe('usePromptActions sleep/wake session recovery', () => {\n     expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID })\n   })\n \n+  it('clears the active and cached turn clocks when stopping a turn', async () => {\n+    const states: Record<string, unknown>[] = []\n+    const requestGateway = vi.fn(async () => ({}) as never)\n+    $turnStartedAt.set(1_700_000_000_000)\n+\n+    let handle: HarnessHandle | null = null\n+    await actRender(\n+      <Harness\n+        onReady={h => (handle = h)}\n+        onSeedState={state => states.push(state)}\n+        refreshSessions={async () => undefined}\n+        requestGateway={requestGateway}\n+      />\n+    )\n+\n+    await handle!.cancelRun()\n+\n+    expect($turnStartedAt.get()).toBeNull()\n+    expect(states.at(-1)).toMatchObject({\n+      awaitingResponse: false,\n+      busy: false,\n+      interrupted: true,\n+      turnStartedAt: null\n+    })\n+  })\n+\n   it('surfaces the original error (no resume) when the failure is not \"session not found\"', async () => {\n     const calls: string[] = []\n     const states: Record<string, unknown>[] = []"]},{"file":"apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts","hunks":["@@ -21,7 +21,15 @@ import { resetSessionBackground } from '@/store/composer-status'\n import { clearNotifications, notify, notifyError } from '@/store/notifications'\n import { clearPreviewArtifacts } from '@/store/preview-status'\n import { clearAllPrompts } from '@/store/prompts'\n-import { $busy, $connection, $messages, setAwaitingResponse, setBusy, setMessages } from '@/store/session'\n+import {\n+  $busy,\n+  $connection,\n+  $messages,\n+  setAwaitingResponse,\n+  setBusy,\n+  setMessages,\n+  setTurnStartedAt\n+} from '@/store/session'\n import { clearSessionSubagents } from '@/store/subagents'\n import { clearSessionTodos } from '@/store/todos'\n ","@@ -501,6 +509,7 @@ export function usePromptActions({\n     }\n \n     setAwaitingResponse(false)\n+    setTurnStartedAt(null)\n \n     const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) =>\n       messages","@@ -526,7 +535,8 @@ export function usePromptActions({\n         streamId: null,\n         pendingBranchGroup: null,\n         needsInput: false,\n-        interrupted: true\n+        interrupted: true,\n+        turnStartedAt: null\n       }\n     })\n "]},{"file":"apps/desktop/src/components/assistant-ui/thread/status.test.tsx","hunks":["@@ -0,0 +1,56 @@\n+import { act, cleanup, render, screen } from '@testing-library/react'\n+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n+\n+import { __resetElapsedTimerRegistryForTests } from '@/components/chat/activity-timer'\n+import { I18nProvider } from '@/i18n'\n+import { $activeSessionId, $turnStartedAt } from '@/store/session'\n+\n+import { ResponseLoadingIndicator } from './status'\n+\n+function renderIndicator() {\n+  return render(\n+    <I18nProvider configClient={null} initialLocale=\"en\">\n+      <ResponseLoadingIndicator />\n+    </I18nProvider>\n+  )\n+}\n+\n+describe('ResponseLoadingIndicator timer', () => {\n+  beforeEach(() => {\n+    vi.useFakeTimers()\n+    vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))\n+    __resetElapsedTimerRegistryForTests()\n+  })\n+\n+  afterEach(() => {\n+    cleanup()\n+    $activeSessionId.set(null)\n+    $turnStartedAt.set(null)\n+    __resetElapsedTimerRegistryForTests()\n+    vi.useRealTimers()\n+  })\n+\n+  it('preserves each running session timer while switching between sessions', () => {\n+    $activeSessionId.set('session-a')\n+    $turnStartedAt.set(Date.now())\n+    const sessionA = renderIndicator()\n+\n+    act(() => vi.advanceTimersByTime(5_000))\n+    expect(screen.getByText('5s')).toBeTruthy()\n+    sessionA.unmount()\n+\n+    $activeSessionId.set('session-b')\n+    $turnStartedAt.set(Date.now())\n+    const sessionB = renderIndicator()\n+\n+    act(() => vi.advanceTimersByTime(3_000))\n+    expect(screen.getByText('3s')).toBeTruthy()\n+    sessionB.unmount()\n+\n+    $activeSessionId.set('session-a')\n+    $turnStartedAt.set(new Date('2026-01-01T00:00:00.000Z').getTime())\n+    renderIndicator()\n+\n+    expect(screen.getByText('8s')).toBeTruthy()\n+  })\n+})"]},{"file":"apps/desktop/src/components/assistant-ui/thread/status.tsx","hunks":["@@ -11,6 +11,7 @@ import { cn } from '@/lib/utils'\n import { $backgroundResume } from '@/store/background-delegation'\n import { $compactionActive } from '@/store/compaction'\n import { $activeSessionAwaitingInput } from '@/store/prompts'\n+import { $activeSessionId, $turnStartedAt } from '@/store/session'\n \n const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({\n   children,","@@ -36,6 +37,13 @@ const CompactionHint: FC = () => (\n   <span className=\"shimmer min-w-0 truncate text-muted-foreground/55\">{COMPACTION_LABEL}</span>\n )\n \n+function useActiveTurnTimerKey(): string | undefined {\n+  const activeSessionId = useStore($activeSessionId)\n+  const turnStartedAt = useStore($turnStartedAt)\n+\n+  return activeSessionId && turnStartedAt ? `turn:${activeSessionId}:${turnStartedAt}` : undefined\n+}\n+\n export const CenteredThreadSpinner: FC = () => {\n   const { t } = useI18n()\n ","@@ -59,7 +67,8 @@ export const CenteredThreadSpinner: FC = () => {\n \n export const ResponseLoadingIndicator: FC = () => {\n   const { t } = useI18n()\n-  const elapsed = useElapsedSeconds()\n+  const timerKey = useActiveTurnTimerKey()\n+  const elapsed = useElapsedSeconds(true, timerKey)\n   const compacting = useStore($compactionActive)\n \n   return (","@@ -134,6 +143,7 @@ export const StreamStallIndicator: FC = () => {\n \n   const [stalled, setStalled] = useState(false)\n   const compacting = useStore($compactionActive)\n+  const turnTimerKey = useActiveTurnTimerKey()\n   // A pending clarify / approval / sudo / secret means the turn is paused on the\n   // user, not working — so don't resurrect the \"thinking\" timer while they\n   // decide (matches the pet's awaitingInput pose taking priority over busy).","@@ -147,7 +157,7 @@ export const StreamStallIndicator: FC = () => {\n   }, [activity])\n \n   const active = (stalled || compacting) && !awaitingInput\n-  const elapsed = useElapsedSeconds(active)\n+  const elapsed = useElapsedSeconds(active, compacting ? turnTimerKey : undefined)\n \n   if (!active) {\n     return null\n"]}]},{"kind":"pr","number":122791,"title":"fix(desktop): clear compaction overlay on Stop and de-duplicate tool title verbs","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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"]}],"trust":{"default_tier":"verified","includes_suggested":false}}