Merge pull request #37908 from NousResearch/bb/desktop-concurrent-session-loss

fix(desktop): keep in-flight new chats from vanishing on refresh
This commit is contained in:
brooklyn! 2026-06-03 00:29:13 -05:00 committed by GitHub
commit feb50eee70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 87 additions and 6 deletions

View file

@ -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 {

View file

@ -65,7 +65,7 @@ interface PromptActionsOptions {
activeSessionIdRef: MutableRefObject<string | null>
busyRef: MutableRefObject<boolean>
branchCurrentSession: () => Promise<boolean>
createBackendSessionForSend: () => Promise<string | null>
createBackendSessionForSend: (preview?: string | null) => Promise<string | null>
handleSkinCommand: (arg: string) => string
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
selectedStoredSessionIdRef: MutableRefObject<string | null>
@ -296,7 +296,7 @@ export function usePromptActions({
if (!sessionId) {
try {
sessionId = await createBackendSessionForSend()
sessionId = await createBackendSessionForSend(visibleText)
} catch (err) {
dropOptimistic(null)
releaseBusy()

View file

@ -303,7 +303,7 @@ export function useSessionActions({
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
)
const createBackendSessionForSend = useCallback(async (): Promise<string | null> => {
const createBackendSessionForSend = useCallback(async (preview: string | null = null): Promise<string | null> => {
const startingActiveSessionId = activeSessionIdRef.current
const startingStoredSessionId = selectedStoredSessionIdRef.current
const startingRouteToken = getRouteToken()
@ -330,7 +330,11 @@ export function useSessionActions({
ensureSessionState(created.session_id, stored)
if (stored) {
upsertOptimisticSession(created, stored)
// Seed the sidebar preview with the user's first message so the row
// reads meaningfully while the turn is in flight, instead of flashing
// "Untitled session" until the turn persists and auto-title runs. The
// server later returns its own preview/title and supersedes this.
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
navigate(sessionRoute(stored), { replace: true })
}

View file

@ -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'])
})
})

View file

@ -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[]>([])