From 55a76ec6695d696e064e9ee7f56eb9ba1fa1c3b3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 3 Jun 2026 00:21:05 -0500 Subject: [PATCH] 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. --- apps/desktop/src/app/desktop-controller.tsx | 9 ++++- apps/desktop/src/store/session.test.ts | 45 ++++++++++++++++++++- apps/desktop/src/store/session.ts | 27 +++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 8aedb6b4608..ad74f3cf49e 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -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 { diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index d9d2befb7e4..6851652662f 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -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 => ({ 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']) + }) +}) diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 187dca7c36b..85c314faff6 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -27,6 +27,33 @@ function updateAtom(store: AppAtom, next: Updater) { export const sessionPinId = (session: Pick): 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(null) export const $gatewayState = atom('idle') export const $sessions = atom([])