Merge pull request #46029 from NousResearch/bb/summarize-gui
fix(desktop): show summarizing indicator during auto-compaction
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user