mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
fix(desktop): keep composer draft across compression tip rotation (#68079)
* fix(desktop): keep composer draft across compression tip rotation Auto-compression swaps the live stored session id while the user may still be typing. Scope the composer/queue key on the lineage root and migrate any tip-keyed draft/queue entries onto that durable key when the tip rotates so the in-progress prompt does not vanish when the response lands. * test(desktop): cover draft survival across compression tip rotation Add regression coverage for migrateSessionDraft, lineage-scoped composer keys, and the rotation path that previously wiped an in-progress draft.
This commit is contained in:
parent
272bbaf792
commit
f657840e06
7 changed files with 233 additions and 7 deletions
|
|
@ -2,7 +2,7 @@ import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type * as React from 'react'
|
||||
import { Suspense, useCallback, useMemo } from 'react'
|
||||
import { Suspense, useCallback, useEffect, useMemo } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||
|
|
@ -24,6 +24,8 @@ import { $pinnedSessionIds } from '@/store/layout'
|
|||
import { $petActive } from '@/store/pet'
|
||||
import { $petOverlayActive } from '@/store/pet-overlay'
|
||||
import { $gatewaySwapTarget, $profiles } from '@/store/profile'
|
||||
import { migrateSessionDraft } from '@/store/composer'
|
||||
import { migrateQueuedPrompts } from '@/store/composer-queue'
|
||||
import {
|
||||
$contextSuggestions,
|
||||
$freshDraftReady,
|
||||
|
|
@ -32,6 +34,7 @@ import {
|
|||
$introSeed,
|
||||
$resumeExhaustedSessionId,
|
||||
$sessions,
|
||||
resolveComposerSessionKey,
|
||||
sessionMatchesStoredId,
|
||||
sessionPinId
|
||||
} from '@/store/session'
|
||||
|
|
@ -276,7 +279,26 @@ export function ChatView({
|
|||
const messagesEmpty = useStore(view.$messagesEmpty)
|
||||
const lastVisibleIsUser = useStore(view.$lastVisibleIsUser)
|
||||
const selectedSessionId = useStore(view.$storedId)
|
||||
const sessions = useStore($sessions)
|
||||
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
|
||||
// Durable composer/queue scope (lineage root) so auto-compression tip rotation
|
||||
// does not wipe an in-progress draft or orphan /queue entries.
|
||||
const queueSessionKey = useMemo(
|
||||
() => resolveComposerSessionKey(selectedSessionId, sessions),
|
||||
[selectedSessionId, sessions]
|
||||
)
|
||||
|
||||
// When the tip row arrives after compression, migrate any tip-keyed stash onto
|
||||
// the durable lineage key before the composer remounts onto that key.
|
||||
useEffect(() => {
|
||||
if (!selectedSessionId || !queueSessionKey || selectedSessionId === queueSessionKey) {
|
||||
return
|
||||
}
|
||||
|
||||
migrateSessionDraft(selectedSessionId, queueSessionKey)
|
||||
migrateQueuedPrompts(selectedSessionId, queueSessionKey)
|
||||
}, [queueSessionKey, selectedSessionId])
|
||||
|
||||
// A tile IS its session — no route involved, never "mismatched".
|
||||
const routedSessionId = isPrimary ? routeSessionId(location.pathname) : selectedSessionId
|
||||
const isRoutedSessionView = Boolean(routedSessionId)
|
||||
|
|
@ -524,7 +546,7 @@ export function ChatView({
|
|||
onSteer={onSteer}
|
||||
onSubmit={onSubmit}
|
||||
onTranscribeAudio={onTranscribeAudio}
|
||||
queueSessionKey={selectedSessionId}
|
||||
queueSessionKey={queueSessionKey}
|
||||
sessionId={activeSessionId}
|
||||
state={chatBarState}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { getSessionMessages, type SessionInfo } from '@/hermes'
|
|||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile } from '@/store/profile'
|
||||
import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects'
|
||||
import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/composer'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$activeSessionStoredIdRotation,
|
||||
|
|
@ -196,6 +197,89 @@ describe('active stored-session id rotation routing', () => {
|
|||
expect($activeSessionStoredIdRotation.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps draft on the previous tip when the new tip row is not loaded yet', async () => {
|
||||
const tipBefore = 'tip-root'
|
||||
const tipAfter = 'tip-new-unloaded'
|
||||
const runtimeSessionId = 'runtime-gap'
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: runtimeSessionId }
|
||||
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: tipBefore }
|
||||
const navigate = vi.fn()
|
||||
|
||||
setSessions([])
|
||||
stashSessionDraft(tipBefore, 'typed during gap', [])
|
||||
setSelectedStoredSessionId(tipBefore)
|
||||
setActiveSessionId(runtimeSessionId)
|
||||
|
||||
render(
|
||||
<StoredIdRotationHarness
|
||||
activeSessionIdRef={activeSessionIdRef}
|
||||
getRoutedStoredSessionId={() => tipBefore}
|
||||
navigate={navigate}
|
||||
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
||||
/>
|
||||
)
|
||||
|
||||
act(() => {
|
||||
setActiveSessionStoredIdRotation({
|
||||
nextStoredSessionId: tipAfter,
|
||||
previousStoredSessionId: tipBefore,
|
||||
runtimeSessionId
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => expect($selectedStoredSessionId.get()).toBe(tipAfter))
|
||||
expect(takeSessionDraft(tipBefore).text).toBe('typed during gap')
|
||||
expect(takeSessionDraft(tipAfter).text).toBe('')
|
||||
|
||||
clearSessionDraft(tipBefore)
|
||||
clearSessionDraft(tipAfter)
|
||||
setActiveSessionId(null)
|
||||
})
|
||||
|
||||
it('parks an in-progress composer draft on the lineage root across tip rotation', async () => {
|
||||
// Desktop draft must stay on the durable composer key (lineage root), not
|
||||
// move onto the fresh tip — ChatBar scopes drafts via resolveComposerSessionKey.
|
||||
const tipBefore = '20260720_062637_ad96b3'
|
||||
const tipAfter = '20260720_071049_a28905'
|
||||
const runtimeSessionId = 'runtime-desktop-thinking'
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: runtimeSessionId }
|
||||
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: tipBefore }
|
||||
const navigate = vi.fn()
|
||||
const typedWhileThinking = 'follow up I am still typing during thinking'
|
||||
|
||||
setSessions([storedSession({ id: tipAfter, message_count: 2, _lineage_root_id: tipBefore })])
|
||||
stashSessionDraft(tipBefore, typedWhileThinking, [])
|
||||
setSelectedStoredSessionId(tipBefore)
|
||||
setActiveSessionId(runtimeSessionId)
|
||||
|
||||
render(
|
||||
<StoredIdRotationHarness
|
||||
activeSessionIdRef={activeSessionIdRef}
|
||||
getRoutedStoredSessionId={() => tipBefore}
|
||||
navigate={navigate}
|
||||
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
||||
/>
|
||||
)
|
||||
|
||||
act(() => {
|
||||
setActiveSessionStoredIdRotation({
|
||||
nextStoredSessionId: tipAfter,
|
||||
previousStoredSessionId: tipBefore,
|
||||
runtimeSessionId
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => expect($selectedStoredSessionId.get()).toBe(tipAfter))
|
||||
// Durable key remains the lineage root — same scope ChatBar will keep using.
|
||||
expect(takeSessionDraft(tipBefore).text).toBe(typedWhileThinking)
|
||||
expect(takeSessionDraft(tipAfter).text).toBe('')
|
||||
|
||||
clearSessionDraft(tipBefore)
|
||||
clearSessionDraft(tipAfter)
|
||||
setActiveSessionId(null)
|
||||
setSessions([])
|
||||
})
|
||||
|
||||
it('does not overwrite a newer route intent before its resume effect has synchronized selection', async () => {
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: 'runtime-A' }
|
||||
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: 'stored-A' }
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import { useI18n } from '@/i18n'
|
|||
import { type ChatMessage, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
||||
import { isMissingRpcMethod } from '@/lib/gateway-rpc'
|
||||
import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import { clearQueuedPrompts } from '@/store/composer-queue'
|
||||
import { migrateSessionDraft } from '@/store/composer'
|
||||
import { clearQueuedPrompts, migrateQueuedPrompts } from '@/store/composer-queue'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
|
|
@ -25,6 +26,7 @@ import {
|
|||
$sessions,
|
||||
$yoloActive,
|
||||
type NewChatWorkspaceTarget,
|
||||
resolveComposerSessionKey,
|
||||
sessionPinId,
|
||||
setActiveSessionId,
|
||||
setActiveSessionStoredIdRotation,
|
||||
|
|
@ -233,14 +235,34 @@ export function useSessionActions({
|
|||
return
|
||||
}
|
||||
|
||||
setSelectedStoredSessionId(storedIdRotation.nextStoredSessionId)
|
||||
selectedStoredSessionIdRef.current = storedIdRotation.nextStoredSessionId
|
||||
// Park unsent draft/queue on the durable lineage key (not the new tip).
|
||||
// ChatBar scopes composer state on resolveComposerSessionKey(); migrating
|
||||
// onto the tip while the composer is still bound to the root can lose newer
|
||||
// live editor text on a brief remount. If the new tip row is not in
|
||||
// $sessions yet, resolveComposerSessionKey falls back to the tip id — prefer
|
||||
// the previous id (usually the lineage root) in that gap.
|
||||
const previousId = storedIdRotation.previousStoredSessionId
|
||||
const nextId = storedIdRotation.nextStoredSessionId
|
||||
const sessions = $sessions.get()
|
||||
const resolvedNext = resolveComposerSessionKey(nextId, sessions)
|
||||
const durableKey =
|
||||
resolvedNext && resolvedNext !== nextId
|
||||
? resolvedNext
|
||||
: (resolveComposerSessionKey(previousId, sessions) ?? previousId)
|
||||
|
||||
migrateSessionDraft(previousId, durableKey)
|
||||
migrateSessionDraft(nextId, durableKey)
|
||||
migrateQueuedPrompts(previousId, durableKey)
|
||||
migrateQueuedPrompts(nextId, durableKey)
|
||||
|
||||
setSelectedStoredSessionId(nextId)
|
||||
selectedStoredSessionIdRef.current = nextId
|
||||
|
||||
// A route overlay/page has no routed session id, but the underlying selected
|
||||
// chat still needs to follow the continuation. Update that selection in
|
||||
// place without navigating out of the surface the user deliberately opened.
|
||||
if (routedStoredSessionId === storedIdRotation.previousStoredSessionId) {
|
||||
navigate(sessionRoute(storedIdRotation.nextStoredSessionId), { replace: true })
|
||||
if (routedStoredSessionId === previousId) {
|
||||
navigate(sessionRoute(nextId), { replace: true })
|
||||
}
|
||||
}, [activeSessionIdRef, getRoutedStoredSessionId, navigate, selectedStoredSessionIdRef, storedIdRotation])
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
addComposerAttachment,
|
||||
clearSessionDraft,
|
||||
type ComposerAttachment,
|
||||
migrateSessionDraft,
|
||||
removeComposerAttachment,
|
||||
SESSION_DRAFTS_STORAGE_KEY,
|
||||
stashSessionDraft,
|
||||
|
|
@ -106,4 +107,29 @@ describe('session drafts', () => {
|
|||
|
||||
expect(takeSessionDraft('session-a').attachments[0]?.label).toBe('doc.pdf')
|
||||
})
|
||||
|
||||
it('migrates a tip-keyed draft onto the post-compression tip', () => {
|
||||
const tipBefore = '20260720_062637_ad96b3'
|
||||
const tipAfter = '20260720_071049_a28905'
|
||||
|
||||
stashSessionDraft(tipBefore, 'half typed while thinking', [])
|
||||
|
||||
expect(migrateSessionDraft(tipBefore, tipAfter)).toBe(true)
|
||||
expect(takeSessionDraft(tipAfter).text).toBe('half typed while thinking')
|
||||
expect(takeSessionDraft(tipBefore).text).toBe('')
|
||||
|
||||
clearSessionDraft(tipAfter)
|
||||
})
|
||||
|
||||
it('does not overwrite a non-empty destination draft during migration', () => {
|
||||
stashSessionDraft('from', 'old tip draft', [])
|
||||
stashSessionDraft('to', 'already typed on new tip', [])
|
||||
|
||||
expect(migrateSessionDraft('from', 'to')).toBe(false)
|
||||
expect(takeSessionDraft('to').text).toBe('already typed on new tip')
|
||||
expect(takeSessionDraft('from').text).toBe('old tip draft')
|
||||
|
||||
clearSessionDraft('from')
|
||||
clearSessionDraft('to')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -171,6 +171,41 @@ export function takeSessionDraft(scope: string | null | undefined): SessionDraft
|
|||
|
||||
export const clearSessionDraft = (scope: string | null | undefined) => stashSessionDraft(scope, '', [])
|
||||
|
||||
/**
|
||||
* Move a stashed composer draft from one session key onto another.
|
||||
*
|
||||
* Auto-compression rotates the live stored tip id (root → continuation) while
|
||||
* the user may still be typing. Drafts keyed on the obsolete tip would otherwise
|
||||
* vanish from the composer when selection follows the new tip. No-op unless both
|
||||
* keys resolve, differ, and the source has content. Does not overwrite a
|
||||
* non-empty destination draft.
|
||||
*/
|
||||
export function migrateSessionDraft(fromKey: string | null | undefined, toKey: string | null | undefined): boolean {
|
||||
const from = draftKey(fromKey)
|
||||
const to = draftKey(toKey)
|
||||
|
||||
if (!fromKey || !toKey || from === to) {
|
||||
return false
|
||||
}
|
||||
|
||||
const source = draftsBySession.get(from)
|
||||
|
||||
if (!source || (!source.text.trim() && source.attachments.length === 0)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dest = draftsBySession.get(to)
|
||||
|
||||
if (dest && (dest.text.trim() || dest.attachments.length > 0)) {
|
||||
return false
|
||||
}
|
||||
|
||||
stashSessionDraft(toKey, source.text, source.attachments)
|
||||
clearSessionDraft(fromKey)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function setComposerDraft(value: string) {
|
||||
$composerDraft.set(value)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
$unreadFinishedSessionIds,
|
||||
applyConfiguredDefaultProjectDir,
|
||||
mergeSessionPage,
|
||||
resolveComposerSessionKey,
|
||||
sessionPinId,
|
||||
setCurrentCwd,
|
||||
setSelectedStoredSessionId,
|
||||
|
|
@ -85,6 +86,21 @@ describe('sessionPinId', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('resolveComposerSessionKey', () => {
|
||||
it('keeps the lineage root across compression tip rotation', () => {
|
||||
const tipBefore = '20260720_062637_ad96b3'
|
||||
const tipAfter = '20260720_071049_a28905'
|
||||
const sessions = [session({ id: tipAfter, _lineage_root_id: tipBefore })]
|
||||
|
||||
expect(resolveComposerSessionKey(tipBefore, [session({ id: tipBefore })])).toBe(tipBefore)
|
||||
expect(resolveComposerSessionKey(tipAfter, sessions)).toBe(tipBefore)
|
||||
})
|
||||
|
||||
it('falls back to the live id when the tip row is not loaded yet', () => {
|
||||
expect(resolveComposerSessionKey('tip-new', [])).toBe('tip-new')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeSessionPage', () => {
|
||||
it('returns the server page untouched when there is nothing to keep', () => {
|
||||
const previous = [session({ id: 'a' }), session({ id: 'b' })]
|
||||
|
|
|
|||
|
|
@ -144,6 +144,27 @@ export const sessionMatchesStoredId = (
|
|||
storedSessionId: string
|
||||
): boolean => session.id === storedSessionId || session._lineage_root_id === storedSessionId
|
||||
|
||||
/**
|
||||
* Stable composer + `/queue` scope for a selected stored session.
|
||||
*
|
||||
* Same durability rule as {@link sessionPinId}: prefer the lineage root so
|
||||
* auto-compression tip rotation does not remount the composer onto an empty
|
||||
* draft/queue key mid-keystroke. Falls back to the live id when the row is
|
||||
* not in the in-memory list yet.
|
||||
*/
|
||||
export function resolveComposerSessionKey(
|
||||
selectedSessionId: string | null | undefined,
|
||||
sessions: readonly Pick<SessionInfo, '_lineage_root_id' | 'id'>[]
|
||||
): string | null {
|
||||
if (!selectedSessionId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const row = sessions.find(session => sessionMatchesStoredId(session, selectedSessionId))
|
||||
|
||||
return row ? sessionPinId(row) : selectedSessionId
|
||||
}
|
||||
|
||||
/** Merge a fresh server session page into the in-memory list, keeping any
|
||||
* row the server omitted that we still want visible — both still-"working"
|
||||
* sessions and pinned sessions.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue