Merge pull request #46029 from NousResearch/bb/summarize-gui

fix(desktop): show summarizing indicator during auto-compaction
This commit is contained in:
brooklyn!
2026-06-14 02:53:14 -05:00
committed by GitHub
7 changed files with 231 additions and 23 deletions
@@ -27,6 +27,7 @@ import { triggerHaptic } from '@/lib/haptics'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { parseTodos } from '@/lib/todos'
import { setClarifyRequest } from '@/store/clarify'
import { setSessionCompacting } from '@/store/compaction'
import { refreshBackgroundProcesses } from '@/store/composer-status'
import { $gateway } from '@/store/gateway'
import { dispatchNativeNotification } from '@/store/native-notifications'
@@ -333,6 +334,8 @@ export function useMessageStream({
const flushHandleRef = useRef<number | null>(null)
const lastFlushAtRef = useRef<number>(0)
const nativeSubagentSessionsRef = useRef<Set<string>>(new Set())
// Turns that auto-compacted: skip post-turn hydrate so live scrollback survives.
const compactedTurnRef = useRef<Set<string>>(new Set())
const flushQueuedDeltas = useCallback(
(sessionId?: string) => {
@@ -639,6 +642,10 @@ export function useMessageStream({
void refreshSessions().catch(() => undefined)
if (compactedTurnRef.current.delete(sessionId)) {
shouldHydrate = false
}
if (shouldHydrate) {
void hydrateFromStoredSession(3, completedState.storedSessionId, sessionId)
}
@@ -825,6 +832,8 @@ export function useMessageStream({
flushQueuedDeltas(sessionId)
clearSessionSubagents(sessionId)
setSessionCompacting(sessionId, false)
compactedTurnRef.current.delete(sessionId)
nativeSubagentSessionsRef.current.delete(sessionId)
if (isActiveEvent) {
@@ -870,6 +879,7 @@ export function useMessageStream({
// session so a background turn finishing can't wipe the active chat's
// prompt, and vice versa.
clearAllPrompts(sessionId)
setSessionCompacting(sessionId, false)
flushQueuedDeltas(sessionId)
@@ -904,10 +914,7 @@ export function useMessageStream({
// terminal/process tool calls are the only things that spawn or reap
// background processes — sync the composer status stack right after.
if (
!sessionInterrupted(sessionId) &&
(payload?.name === 'terminal' || payload?.name === 'process')
) {
if (!sessionInterrupted(sessionId) && (payload?.name === 'terminal' || payload?.name === 'process')) {
void refreshBackgroundProcesses(sessionId)
}
}
@@ -1061,9 +1068,12 @@ export function useMessageStream({
})
}
} else if (event.type === 'status.update') {
// The gateway's notification poller announces background process
// completions / watch matches here — re-sync the status stack.
if (sessionId && payload?.kind === 'process') {
if (sessionId && payload?.kind === 'compacting') {
setSessionCompacting(sessionId, true)
compactedTurnRef.current.add(sessionId)
} else if (sessionId && payload?.kind === 'process') {
// The gateway's notification poller announces background process
// completions / watch matches here — re-sync the status stack.
void refreshBackgroundProcesses(sessionId)
}
} else if (event.type === 'error') {
@@ -1075,6 +1085,8 @@ export function useMessageStream({
// the failed turn (same intent as the message.complete clear).
if (sessionId) {
clearAllPrompts(sessionId)
setSessionCompacting(sessionId, false)
compactedTurnRef.current.delete(sessionId)
}
dispatchNativeNotification({
@@ -96,6 +96,7 @@ import { extractPreviewTargets } from '@/lib/preview-targets'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import { $compactionActive } from '@/store/compaction'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { $connection } from '@/store/session'
@@ -273,10 +274,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
return pickPrimaryPreviewTarget(extractPreviewTargets(completedText))
}, [completedText])
const getMessageText = useCallback(
() => messageContentText(messageRuntime.getState().content),
[messageRuntime]
)
const getMessageText = useCallback(() => messageContentText(messageRuntime.getState().content), [messageRuntime])
const enterRef = useEnterAnimation(isRunning, `assistant-message:${messageId}`)
@@ -339,13 +337,25 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp
</div>
)
// Fixed label while auto-compaction runs — decoupled from backend status text.
const COMPACTION_LABEL = 'Summarizing thread'
const CompactionHint: FC = () => (
<span className="shimmer min-w-0 truncate text-muted-foreground/55">{COMPACTION_LABEL}</span>
)
const ResponseLoadingIndicator: FC = () => {
const { t } = useI18n()
const elapsed = useElapsedSeconds()
const compacting = useStore($compactionActive)
return (
<StatusRow data-slot="aui_response-loading" label={t.assistant.thread.loadingResponse}>
<StatusRow
data-slot="aui_response-loading"
label={compacting ? COMPACTION_LABEL : t.assistant.thread.loadingResponse}
>
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
{compacting && <CompactionHint />}
<ActivityTimerText seconds={elapsed} />
</StatusRow>
)
@@ -380,6 +390,7 @@ const StreamStallIndicator: FC = () => {
})
const [stalled, setStalled] = useState(false)
const compacting = useStore($compactionActive)
useEffect(() => {
setStalled(false)
@@ -388,15 +399,21 @@ const StreamStallIndicator: FC = () => {
return () => window.clearTimeout(id)
}, [activity])
const elapsed = useElapsedSeconds(stalled)
const active = stalled || compacting
const elapsed = useElapsedSeconds(active)
if (!stalled) {
if (!active) {
return null
}
return (
<StatusRow className="mt-1.5" data-slot="aui_stream-stall" label="Hermes is thinking">
<StatusRow
className="mt-1.5"
data-slot="aui_stream-stall"
label={compacting ? COMPACTION_LABEL : 'Hermes is thinking'}
>
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
{compacting && <CompactionHint />}
<ActivityTimerText seconds={elapsed} />
</StatusRow>
)
+53
View File
@@ -0,0 +1,53 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $compactingSessions, $compactionActive, setSessionCompacting } from './compaction'
import { $activeSessionId } from './session'
describe('compaction store', () => {
beforeEach(() => {
$compactingSessions.set({})
$activeSessionId.set(null)
})
afterEach(() => {
$compactingSessions.set({})
$activeSessionId.set(null)
})
it('tracks compaction per session independently', () => {
setSessionCompacting('session-a', true)
setSessionCompacting('session-b', true)
expect($compactingSessions.get()).toEqual({ 'session-a': true, 'session-b': true })
})
it('exposes only the active session via the focus-scoped view', () => {
setSessionCompacting('session-a', true)
expect($compactionActive.get()).toBe(false)
$activeSessionId.set('session-a')
expect($compactionActive.get()).toBe(true)
$activeSessionId.set('session-b')
expect($compactionActive.get()).toBe(false)
})
it('clears a session without disturbing the others', () => {
setSessionCompacting('session-a', true)
setSessionCompacting('session-b', true)
setSessionCompacting('session-a', false)
expect($compactingSessions.get()).toEqual({ 'session-b': true })
})
it('is a no-op when clearing an unknown session', () => {
setSessionCompacting('session-a', true)
const before = $compactingSessions.get()
setSessionCompacting('session-missing', false)
expect($compactingSessions.get()).toBe(before)
})
})
+38
View File
@@ -0,0 +1,38 @@
import { atom, computed } from 'nanostores'
import { $activeSessionId } from './session'
// Per-session flag while auto-compaction runs mid-turn. Without it the
// transcript looks like it reset; per-session so a background chat can't
// clobber the foreground view.
const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
export const $compactingSessions = atom<Record<string, true>>({})
export const $compactionActive = computed(
[$compactingSessions, $activeSessionId],
(sessions, activeId) => keyFor(activeId) in sessions
)
export function setSessionCompacting(sessionId: string | null | undefined, active: boolean): void {
const key = keyFor(sessionId)
const sessions = $compactingSessions.get()
if (active) {
if (key in sessions) {
return
}
$compactingSessions.set({ ...sessions, [key]: true })
return
}
if (!(key in sessions)) {
return
}
const next = { ...sessions }
delete next[key]
$compactingSessions.set(next)
}