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