From 55a76ec6695d696e064e9ee7f56eb9ba1fa1c3b3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 3 Jun 2026 00:21:05 -0500 Subject: [PATCH 1/2] 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([]) From e0a999aa8a22ce24caa5f9748116daec32779ad7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 3 Jun 2026 00:25:19 -0500 Subject: [PATCH 2/2] fix(desktop): label in-flight new chats with the first message The send path created the optimistic sidebar row with a null preview, so a new chat read "Untitled session" until its turn persisted and auto-title ran. With concurrent new chats now preserved across refreshes, several "Untitled session" rows could show at once. Seed the optimistic preview with the user's first message (the branch path already does this) so each in-flight row is labeled immediately. The server's own preview/title supersedes it once the turn persists. --- apps/desktop/src/app/session/hooks/use-prompt-actions.ts | 4 ++-- apps/desktop/src/app/session/hooks/use-session-actions.ts | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 62d685fc701..e1e13b9d4f0 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -65,7 +65,7 @@ interface PromptActionsOptions { activeSessionIdRef: MutableRefObject busyRef: MutableRefObject branchCurrentSession: () => Promise - createBackendSessionForSend: () => Promise + createBackendSessionForSend: (preview?: string | null) => Promise handleSkinCommand: (arg: string) => string requestGateway: (method: string, params?: Record) => Promise selectedStoredSessionIdRef: MutableRefObject @@ -296,7 +296,7 @@ export function usePromptActions({ if (!sessionId) { try { - sessionId = await createBackendSessionForSend() + sessionId = await createBackendSessionForSend(visibleText) } catch (err) { dropOptimistic(null) releaseBusy() diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 7b301f7f3da..a07bfdb64dd 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -303,7 +303,7 @@ export function useSessionActions({ [activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef] ) - const createBackendSessionForSend = useCallback(async (): Promise => { + const createBackendSessionForSend = useCallback(async (preview: string | null = null): Promise => { 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 }) }