mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
fix(desktop): keep in-flight new chats from vanishing on refresh
Creating several sessions in a row (Ctrl-N, type, send, repeat) and waiting for one to finish made the other still-running chats disappear from the sidebar. Root cause: a new session's first user message isn't flushed to the SessionDB until its turn is persisted, so the row's message_count stays 0 mid-response. `refreshSessions()` lists with min_messages=1 and then hard-replaces $sessions. Because every message.complete triggers a refresh, the moment one session finished, the others (still at message_count 0) were filtered out of the server page and dropped from the list. Fix: merge instead of replace. `mergeWorkingSessions()` preserves any session that is still in $workingSessionIds but absent from the server page, so concurrent new chats stay visible until their own turn persists. Optimistic deletes/archives already remove the row from the previous list, so a removed session can't be resurrected by the merge.
This commit is contained in:
parent
d9f7e7ac81
commit
55a76ec669
3 changed files with 79 additions and 2 deletions
|
|
@ -33,6 +33,8 @@ import {
|
|||
$gatewayState,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$workingSessionIds,
|
||||
mergeWorkingSessions,
|
||||
sessionPinId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
|
|
@ -206,7 +208,12 @@ export function DesktopController() {
|
|||
const result = await listSessions(limit, 1)
|
||||
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
setSessions(result.sessions)
|
||||
// Don't hard-replace: a session whose first turn is still in flight has
|
||||
// message_count 0 in the DB, so min_messages=1 omits it. Since every
|
||||
// message.complete refreshes the list, a plain replace would drop the
|
||||
// other still-running new chats the moment one of them finishes. Keep
|
||||
// any working session the server hasn't surfaced yet.
|
||||
setSessions(prev => mergeWorkingSessions(prev, result.sessions, $workingSessionIds.get()))
|
||||
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
|
||||
}
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|||
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { sessionPinId } from './session'
|
||||
import { mergeWorkingSessions, sessionPinId } from './session'
|
||||
|
||||
const session = (over: Partial<SessionInfo>): SessionInfo => ({
|
||||
archived: false,
|
||||
|
|
@ -34,3 +34,46 @@ describe('sessionPinId', () => {
|
|||
expect(sessionPinId(session({ id: 'tip', _lineage_root_id: 'root' }))).toBe('root')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeWorkingSessions', () => {
|
||||
it('returns the server page untouched when nothing is working', () => {
|
||||
const previous = [session({ id: 'a' }), session({ id: 'b' })]
|
||||
const incoming = [session({ id: 'a' })]
|
||||
|
||||
expect(mergeWorkingSessions(previous, incoming, [])).toBe(incoming)
|
||||
})
|
||||
|
||||
it('keeps a still-working session the server omitted', () => {
|
||||
// Repro of the disappearing-sessions bug: A finished and is returned by the
|
||||
// server, but B and C are mid-first-response (message_count 0 in the DB) so
|
||||
// listSessions(min_messages=1) skips them. They must survive the refresh.
|
||||
const previous = [session({ id: 'c' }), session({ id: 'b' }), session({ id: 'a' })]
|
||||
const incoming = [session({ id: 'a', message_count: 2 })]
|
||||
|
||||
const merged = mergeWorkingSessions(previous, incoming, ['b', 'c'])
|
||||
|
||||
expect(merged.map(s => s.id)).toEqual(['c', 'b', 'a'])
|
||||
// The finished session comes from the fresh server payload, not the stale
|
||||
// optimistic copy.
|
||||
expect(merged.find(s => s.id === 'a')?.message_count).toBe(2)
|
||||
})
|
||||
|
||||
it('does not duplicate a working session the server already returned', () => {
|
||||
const previous = [session({ id: 'b' }), session({ id: 'a' })]
|
||||
const incoming = [session({ id: 'b', message_count: 4 }), session({ id: 'a' })]
|
||||
|
||||
const merged = mergeWorkingSessions(previous, incoming, ['b'])
|
||||
|
||||
expect(merged.map(s => s.id)).toEqual(['b', 'a'])
|
||||
expect(merged.find(s => s.id === 'b')?.message_count).toBe(4)
|
||||
})
|
||||
|
||||
it('never resurrects a non-working session the server dropped', () => {
|
||||
// A deleted/archived session is removed from `previous` optimistically and
|
||||
// is not in the working set, so it must stay gone after a refresh.
|
||||
const previous = [session({ id: 'b' }), session({ id: 'gone' })]
|
||||
const incoming = [session({ id: 'b' })]
|
||||
|
||||
expect(mergeWorkingSessions(previous, incoming, ['b']).map(s => s.id)).toEqual(['b'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -27,6 +27,33 @@ function updateAtom<T>(store: AppAtom<T>, next: Updater<T>) {
|
|||
export const sessionPinId = (session: Pick<SessionInfo, '_lineage_root_id' | 'id'>): string =>
|
||||
session._lineage_root_id ?? session.id
|
||||
|
||||
/** Merge a fresh server session page into the in-memory list, keeping any
|
||||
* still-"working" session the server omitted.
|
||||
*
|
||||
* A brand-new session's first user message isn't flushed to the SessionDB
|
||||
* until its turn is persisted, so `listSessions(min_messages=1)` skips
|
||||
* sessions that are mid-first-response. Because every `message.complete`
|
||||
* triggers a full refresh, a hard replace makes concurrent new chats vanish
|
||||
* the instant any one of them finishes. Preserving the working-but-absent
|
||||
* rows keeps them visible until their own turn persists and the server
|
||||
* starts returning them. Optimistic deletes/archives already drop the row
|
||||
* from `previous`, so a removed session can't be resurrected here. */
|
||||
export function mergeWorkingSessions(
|
||||
previous: SessionInfo[],
|
||||
incoming: SessionInfo[],
|
||||
workingIds: readonly string[]
|
||||
): SessionInfo[] {
|
||||
if (workingIds.length === 0) {
|
||||
return incoming
|
||||
}
|
||||
|
||||
const working = new Set(workingIds)
|
||||
const incomingIds = new Set(incoming.map(session => session.id))
|
||||
const survivors = previous.filter(session => working.has(session.id) && !incomingIds.has(session.id))
|
||||
|
||||
return survivors.length ? [...survivors, ...incoming] : incoming
|
||||
}
|
||||
|
||||
export const $connection = atom<HermesConnection | null>(null)
|
||||
export const $gatewayState = atom('idle')
|
||||
export const $sessions = atom<SessionInfo[]>([])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue