diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index c1a5c358..c02b249b 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -1122,17 +1122,25 @@ const MarkdownRendererImpl: React.FC = ({ ? part : null; const runtimeKey = getRuntimeKey(); + // Memoized on scalar identities, not the part object: sync-store reducers + // recreate part objects on unrelated updates, and an object-identity dep + // re-ran the async render pipeline for identical content. + const settledSessionID = settledPart?.sessionID; + const settledMessageID = settledPart?.messageID; + const settledPartID = settledPart?.id; const domCacheKey = React.useMemo(() => { // Streaming, unfinished, oversized, and identity-less Markdown continues // through the normal rendering pipeline and never retains detached DOM. - if (isStreaming || !settledPart || content.length === 0 || content.length > MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS) return null; + if (isStreaming || !settledSessionID || !settledMessageID || !settledPartID || content.length === 0 || content.length > MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS) return null; + // content.length is a cheap fingerprint: an edited or reverted part that + // re-materializes under the same id must not restore the old DOM. return { - scope: `${runtimeKey}\0${settledPart.sessionID}`, - id: `${settledPart.messageID}\0${settledPart.id}\0${imageMode}`, + scope: `${runtimeKey}\0${settledSessionID}`, + id: `${settledMessageID}\0${settledPartID}\0${imageMode}\0${content.length}`, locale, directory: effectiveDirectory, }; - }, [content.length, effectiveDirectory, imageMode, isStreaming, locale, runtimeKey, settledPart]); + }, [content.length, effectiveDirectory, imageMode, isStreaming, locale, runtimeKey, settledSessionID, settledMessageID, settledPartID]); // Identity for the fade-in wrapper: a new part/message restarts the animation. const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`; diff --git a/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.ts b/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.ts index 09f94291..faa3f37f 100644 --- a/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.ts +++ b/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.ts @@ -77,10 +77,14 @@ export class DetachedMarkdownDomCache { const entry = session.get(entryKey); if (entry === undefined) return null; + // A mismatched probe (different locale or directory for the same part) + // must not destroy the entry — the matching renderer may still come for + // it. Only a real hit transfers ownership out of the cache. + if (entry.locale !== key.locale || entry.directory !== key.directory) return null; + // A fragment is a move-only resource; taking it removes cache ownership. session.delete(entryKey); if (session.size === 0) this.sessions.delete(sessionKey); - if (entry.locale !== key.locale || entry.directory !== key.directory) return null; return entry.fragment; } diff --git a/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx b/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx index c5ae7a47..9ed3e620 100644 --- a/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx +++ b/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx @@ -10,6 +10,8 @@ import { useArchivedAutoFolders } from '../folders/useArchivedAutoFolders'; import { ProjectSessionSelectionEffect } from '../projects/useProjectSessionSelection'; import type { WorktreeMetadata } from '@/types/worktree'; import { useRecentSessionCollection, useSessionProjectCollection } from './sessionCollection'; +import { buildSessionBootstrapDemands } from './sessionBootstrapDemands'; +import { useChildStoreManager } from '@/sync/sync-context'; import { createSessionOwnershipIndex } from '../sessions/sessionOwnership'; import { useProjectSessionLists } from '../projects/useProjectSessionLists'; import { useSessionSidebarSections } from '../projects/useSessionSidebarSections'; @@ -192,6 +194,26 @@ const VisibleSessionProjects: React.FC = ({ topol buildGroupSearchText, foldersMap, }); + + // Second bootstrap-demand owner: the layout-level useSessionListSync keeps + // every known directory alive at background priority even when the sidebar + // is hidden, but only the visible collection knows which projects and + // groups are EXPANDED. Without this owner, expanded projects bootstrapped + // serialized at background priority (one directory at a time) instead of + // concurrently at expanded priority. + const childStores = useChildStoreManager(); + const expansionDemandOwner = `session-collection-expansion:${React.useId()}`; + React.useEffect(() => { + childStores.setBootstrapDemand(expansionDemandOwner, buildSessionBootstrapDemands({ + projectSections, + activeProjectId: view.activeProjectId, + collapsedProjects: projectView.collapsedProjects, + collapsedGroups: projectView.collapsedGroups, + currentDirectory: null, + currentSessionDirectory: null, + })); + return () => childStores.clearBootstrapDemand(expansionDemandOwner); + }, [childStores, expansionDemandOwner, projectSections, projectView.collapsedProjects, projectView.collapsedGroups, view.activeProjectId]); const source = view.useGroupedSections ? sectionsForRender : flatSectionsForRender; const sectionsForSidebarRender = React.useMemo(() => view.showInlineArchived ? source : source.map((section) => ( section.groups.some((group) => group.isArchivedBucket) diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index b12bb094..e6668fad 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -1884,12 +1884,22 @@ export const syncDesktopSettings = async (): Promise => { // Each step is wrapped in try/catch so a failure in one side-effect (e.g. // a TypeError from writing to a contextBridge-protected global) doesn't // prevent server settings from reaching the Zustand store. + // Local changes sitting in the debounce buffer are not yet tracked as + // mutations (record() only stores while a request is in flight), so a GET + // racing the debounce window would briefly revert them. Reapply the + // pending buffer over every reconciled result. + const overlayPendingChanges = (settings: DesktopSettings): DesktopSettings => { + if (!_pendingSettingsChanges || !_pendingSettingsContext) return settings; + if (!isSettingsRuntimeContextCurrent(_pendingSettingsContext)) return settings; + return { ...settings, ..._pendingSettingsChanges }; + }; + const applySettings = async (loadedSettings: DesktopSettings) => { if (!isSettingsRuntimeContextCurrent(context)) return; - let settings = _settingsMutationTracker.reconcile(loadedSettings, operation); + let settings = overlayPendingChanges(_settingsMutationTracker.reconcile(loadedSettings, operation)); await waitForHydration(); if (!isSettingsRuntimeContextCurrent(context)) return; - settings = _settingsMutationTracker.reconcile(loadedSettings, operation); + settings = overlayPendingChanges(_settingsMutationTracker.reconcile(loadedSettings, operation)); const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true || settings.draftStartersScheduleTaskAdded !== true; // `autoSaveEnabled` is new to the settings backend. Until the server has a