From 930089b2188474fc995406a604d038469d947cf7 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:29:11 +0530 Subject: [PATCH] fix race condition in chat history --- apps/x/apps/main/src/ipc.ts | 14 ++++++++++++++ apps/x/apps/main/src/main.ts | 15 +++++++++++---- apps/x/apps/renderer/src/App.tsx | 30 ++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 9c7d9783..520ed370 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -702,6 +702,19 @@ export function startSessionsWatcher(): void { sessionsWatcher = sessionBus.subscribe((event) => emitSessionEvent(event)); } +// The renderer window is created before the session-index startup scan +// finishes, so an early sessions:list could observe a partially built index +// (the scan runs oldest-first — exactly the newest chats would be missing). +// sessions:list awaits this deferred; main.ts resolves it when the scan +// settles (success or failure, so the list never hangs). +let resolveSessionsIndexReady: () => void; +const sessionsIndexReady = new Promise((resolve) => { + resolveSessionsIndexReady = resolve; +}); +export function markSessionsIndexReady(): void { + resolveSessionsIndexReady(); +} + let servicesWatcher: (() => void) | null = null; export async function startServicesWatcher(): Promise { if (servicesWatcher) { @@ -938,6 +951,7 @@ export function setupIpcHandlers() { return { sessionId }; }, 'sessions:list': async () => { + await sessionsIndexReady; return { sessions: container.resolve('sessions').listSessions() }; }, 'sessions:get': async (_event, args) => { diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 7af32d35..d8184cf1 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, typ import path from "node:path"; import { setupIpcHandlers, - startRunsWatcher, startSessionsWatcher, + startRunsWatcher, startSessionsWatcher, markSessionsIndexReady, startChannelsWatcher, startCodeSessionStatusWatcher, startServicesWatcher, @@ -411,9 +411,16 @@ app.whenReady().then(async () => { console.error('[runs-migration] pass failed:', error); } - // New runtime: build the in-memory session index (startup scan) before the - // renderer can list sessions, then forward the session bus to windows. - await container.resolve('sessions').initialize(); + // New runtime: build the in-memory session index (startup scan), then + // forward the session bus to windows. The renderer window is already up and + // may have called sessions:list — that handler blocks on + // markSessionsIndexReady, which must fire even if the scan throws so the + // list never hangs. + try { + await container.resolve('sessions').initialize(); + } finally { + markSessionsIndexReady(); + } startSessionsWatcher(); // Mobile channels (WhatsApp/Telegram bridge): needs the session index, so diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 0b473720..17518155 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -10,6 +10,7 @@ import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; import { useSessionChat } from '@/hooks/useSessionChat'; +import { subscribeSessionFeed } from '@/lib/session-chat/feed'; import { ChatHeader } from './components/chat-header'; import { ChatEmptyState } from './components/chat-empty-state'; import { ChatInputWithMentions, type CallPreset, type PermissionMode, type StagedAttachment } from './components/chat-input-with-mentions'; @@ -2254,6 +2255,35 @@ function App() { loadRuns() }, [loadRuns]) + // Keep the runs list live: the session index publishes index-changed on + // every write (session created, turn settled, title change, delete), so the + // list stays current without re-fetching. + useEffect(() => { + return subscribeSessionFeed((event) => { + if (event.kind !== 'index-changed') return + setRuns((prev) => { + if (event.entry === null) { + return prev.filter((run) => run.id !== event.sessionId) + } + const next: RunListItem = { + id: event.entry.sessionId, + title: event.entry.title ?? 'New chat', + createdAt: event.entry.createdAt, + modifiedAt: event.entry.updatedAt, + agentId: event.entry.lastAgentId ?? 'copilot', + } + // Re-sort: chat-header and home-view slice the top of this list + // without sorting, so it must stay newest-first like sessions:list. + const recency = (run: RunListItem) => { + const ms = new Date(run.modifiedAt).getTime() + return Number.isNaN(ms) ? 0 : ms + } + return [...prev.filter((run) => run.id !== next.id), next] + .sort((a, b) => recency(b) - recency(a)) + }) + }) + }, []) + const [bgTaskSummaries, setBgTaskSummaries] = useState