From 8204143810e0846723a2ef39efd213de57d7ef5b Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Tue, 9 Dec 2025 14:05:10 +0000 Subject: [PATCH] Improve session cache eviction --- .../components/instance/instance-shell.tsx | 120 ++++++++++++++-- packages/ui/src/components/message-block.tsx | 4 + .../ui/src/components/message-section.tsx | 68 +++++++-- .../src/components/session/session-view.tsx | 26 +++- packages/ui/src/components/virtual-item.tsx | 4 +- packages/ui/src/stores/message-v2/bus.ts | 24 +++- .../src/stores/message-v2/instance-store.ts | 136 ++++++++++-------- .../stores/message-v2/record-display-cache.ts | 7 + packages/ui/src/stores/session-state.ts | 27 +++- packages/ui/src/stores/sessions.ts | 3 +- 10 files changed, 319 insertions(+), 100 deletions(-) diff --git a/packages/ui/src/components/instance/instance-shell.tsx b/packages/ui/src/components/instance/instance-shell.tsx index 81e8d7f3..80949998 100644 --- a/packages/ui/src/components/instance/instance-shell.tsx +++ b/packages/ui/src/components/instance/instance-shell.tsx @@ -1,9 +1,11 @@ -import { Show, createMemo, createSignal, onCleanup, onMount, type Component } from "solid-js" +import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, type Component } from "solid-js" import type { Accessor } from "solid-js" import type { Instance } from "../../types/instance" import type { Command } from "../../lib/commands" import { activeParentSessionId, activeSessionId as activeSessionMap, getSessionFamily, setActiveSession } from "../../stores/sessions" import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry" +import { messageStoreBus } from "../../stores/message-v2/bus" +import { clearSessionRenderCache } from "../message-block" import { buildCustomCommandEntries } from "../../lib/command-utils" import { getCommands as getInstanceCommands } from "../../stores/commands" import { isOpen as isCommandPaletteOpen, hideCommandPalette } from "../../stores/command-palette" @@ -34,11 +36,14 @@ interface InstanceShellProps { const DEFAULT_SESSION_SIDEBAR_WIDTH = 350 const MOBILE_SIDEBAR_BREAKPOINT = 1024 +const SESSION_CACHE_LIMIT = 2 const InstanceShell: Component = (props) => { const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH) const [isCompactLayout, setIsCompactLayout] = createSignal(false) const [isSidebarOpen, setIsSidebarOpen] = createSignal(true) + const [cachedSessionIds, setCachedSessionIds] = createSignal([]) + const [pendingEvictions, setPendingEvictions] = createSignal([]) const sidebarId = `session-sidebar-${props.instance.id}` let previousIsCompact = false @@ -77,12 +82,17 @@ const InstanceShell: Component = (props) => { return activeSessionMap().get(props.instance.id) || null }) + const parentSessionIdForInstance = createMemo(() => { + return activeParentSessionId().get(props.instance.id) || null + }) + const activeSessionForInstance = createMemo(() => { const sessionId = activeSessionIdForInstance() if (!sessionId || sessionId === "info") return null return activeSessions().get(sessionId) ?? null }) + const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id))) const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()]) const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id)) @@ -97,6 +107,74 @@ const InstanceShell: Component = (props) => { setActiveSession(props.instance.id, sessionId) } + const evictSession = (sessionId: string) => { + if (!sessionId) return + log.info("Evicting cached session", { instanceId: props.instance.id, sessionId }) + const store = messageStoreBus.getInstance(props.instance.id) + store?.clearSession(sessionId) + clearSessionRenderCache(props.instance.id, sessionId) + } + + const scheduleEvictions = (ids: string[]) => { + if (!ids.length) return + setPendingEvictions((current) => { + const existing = new Set(current) + const next = [...current] + ids.forEach((id) => { + if (!existing.has(id)) { + next.push(id) + existing.add(id) + } + }) + return next + }) + } + + createEffect(() => { + const pending = pendingEvictions() + if (!pending.length) return + const cached = new Set(cachedSessionIds()) + const remaining: string[] = [] + pending.forEach((id) => { + if (cached.has(id)) { + remaining.push(id) + } else { + evictSession(id) + } + }) + if (remaining.length !== pending.length) { + setPendingEvictions(remaining) + } + }) + + createEffect(() => { + const sessionsMap = activeSessions() + const parentId = parentSessionIdForInstance() + const activeId = activeSessionIdForInstance() + setCachedSessionIds((current) => { + const next: string[] = [] + const append = (id: string | null) => { + if (!id || id === "info") return + if (!sessionsMap.has(id)) return + if (next.includes(id)) return + next.push(id) + } + + append(parentId) + append(activeId) + current.forEach((id) => append(id)) + + const limit = parentId ? SESSION_CACHE_LIMIT + 1 : SESSION_CACHE_LIMIT + const trimmed = next.length > limit ? next.slice(0, limit) : next + const trimmedSet = new Set(trimmed) + const removed = current.filter((id) => !trimmedSet.has(id)) + if (removed.length) { + scheduleEvictions(removed) + } + return trimmed + }) + }) + return ( <> 0} fallback={}> @@ -212,8 +290,7 @@ const InstanceShell: Component = (props) => { when={activeSessionIdForInstance() === "info"} fallback={ 0 && activeSessionIdForInstance()} fallback={
@@ -223,18 +300,31 @@ const InstanceShell: Component = (props) => {
} > - {(sessionId) => ( - setIsSidebarOpen(true)} - forceCompactStatusLayout={shouldShowSidebarToggle()} - /> - )} + + {(sessionId) => { + const isActive = () => activeSessionIdForInstance() === sessionId + return ( +
+ setIsSidebarOpen(true)} + forceCompactStatusLayout={shouldShowSidebarToggle()} + isActive={isActive()} + /> +
+ ) + }} +
} > diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index 9989e5a8..ecc54031 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -125,6 +125,10 @@ function makeSessionCacheKey(instanceId: string, sessionId: string) { return `${instanceId}:${sessionId}` } +export function clearSessionRenderCache(instanceId: string, sessionId: string) { + renderCaches.delete(makeSessionCacheKey(instanceId, sessionId)) +} + function getSessionRenderCache(instanceId: string, sessionId: string): SessionRenderCache { const key = makeSessionCacheKey(instanceId, sessionId) let cache = renderCaches.get(key) diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index e1ebbbc3..c49a55f1 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -30,6 +30,8 @@ export interface MessageSectionProps { onRevert?: (messageId: string) => void onFork?: (messageId?: string) => void registerScrollToBottom?: (fn: () => void) => void + requestScrollToBottom?: () => void + isActive: boolean showSidebarToggle?: boolean onSidebarToggle?: () => void forceCompactStatusLayout?: boolean @@ -134,7 +136,12 @@ export default function MessageSection(props: MessageSectionProps) { const [scrollElement, setScrollElement] = createSignal() const [topSentinel, setTopSentinel] = createSignal(null) - const [bottomSentinel, setBottomSentinel] = createSignal(null) + const [bottomSentinelSignal, setBottomSentinelSignal] = createSignal(null) + const bottomSentinel = () => bottomSentinelSignal() + const setBottomSentinel = (element: HTMLDivElement | null) => { + setBottomSentinelSignal(element) + } + const [scrollToBottomRequest, setScrollToBottomRequest] = createSignal(false) const [autoScroll, setAutoScroll] = createSignal(true) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) @@ -190,6 +197,9 @@ export default function MessageSection(props: MessageSectionProps) { function setContainerRef(element: HTMLDivElement | null) { containerRef = element || undefined + if (import.meta.env?.DEV) { + console.debug("[MessageSection] setContainerRef", props.sessionId, Boolean(containerRef)) + } setScrollElement(containerRef) attachScrollIntentListeners(containerRef) if (!containerRef) { @@ -207,8 +217,13 @@ export default function MessageSection(props: MessageSectionProps) { function updateScrollIndicatorsFromVisibility() { const hasItems = messageIds().length > 0 - setShowScrollBottomButton(hasItems && !bottomSentinelVisible()) - setShowScrollTopButton(hasItems && !topSentinelVisible()) + const bottomVisible = bottomSentinelVisible() + const topVisible = topSentinelVisible() + if (import.meta.env?.DEV) { + console.debug("[MessageSection] sentinel visibility", props.sessionId, { bottomVisible, topVisible }) + } + setShowScrollBottomButton(hasItems && !bottomVisible) + setShowScrollTopButton(hasItems && !topVisible) } function scheduleScrollPersist() { @@ -216,13 +231,20 @@ export default function MessageSection(props: MessageSectionProps) { pendingScrollPersist = requestAnimationFrame(() => { pendingScrollPersist = null if (!containerRef) return - scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX }) + // scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX }) }) } function scrollToBottom(immediate = false) { if (!containerRef) return const sentinel = bottomSentinel() + if (import.meta.env?.DEV) { + console.debug("[MessageSection] scrollToBottom", props.sessionId, { + immediate, + hasSentinel: Boolean(sentinel), + bottomVisible: bottomSentinelVisible(), + }) + } const behavior = immediate ? "auto" : "smooth" if (!immediate) { suppressAutoScrollOnce = true @@ -235,6 +257,9 @@ export default function MessageSection(props: MessageSectionProps) { function scrollToTop(immediate = false) { if (!containerRef) return const behavior = immediate ? "auto" : "smooth" + if (import.meta.env?.DEV) { + console.debug("[MessageSection] scrollToTop", props.sessionId, { immediate }) + } setAutoScroll(false) topSentinel()?.scrollIntoView({ block: "start", inline: "nearest", behavior }) scheduleScrollPersist() @@ -357,6 +382,17 @@ export default function MessageSection(props: MessageSectionProps) { } }) + createEffect(() => { + const active = props.isActive + const container = containerRef + if (!container) return + if (active) { + requestAnimationFrame(() => requestAnimationFrame(() => scrollToBottom(true))) + } else { + requestAnimationFrame(() => container.scrollTo({ top: 0, behavior: "auto" })) + } + }) + createEffect(() => { if (!props.onQuoteSelection) { clearQuoteSelection() @@ -392,16 +428,16 @@ export default function MessageSection(props: MessageSectionProps) { if (!target || loading || hasRestoredScroll) return - scrollCache.restore(target, { - onApplied: (snapshot) => { - if (snapshot) { - setAutoScroll(snapshot.atBottom) - } else { - setAutoScroll(bottomSentinelVisible()) - } - updateScrollIndicatorsFromVisibility() - }, - }) + // scrollCache.restore(target, { + // onApplied: (snapshot) => { + // if (snapshot) { + // setAutoScroll(snapshot.atBottom) + // } else { + // setAutoScroll(bottomSentinelVisible()) + // } + // updateScrollIndicatorsFromVisibility() + // }, + // }) hasRestoredScroll = true }) @@ -522,7 +558,7 @@ export default function MessageSection(props: MessageSectionProps) { detachScrollIntentListeners() } if (containerRef) { - scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX }) + // scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX }) } clearQuoteSelection() }) @@ -591,6 +627,8 @@ export default function MessageSection(props: MessageSectionProps) { onContentRendered={handleContentRendered} setBottomSentinel={setBottomSentinel} /> + +
diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index fe3f5ae1..d1398a99 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -1,4 +1,4 @@ -import { Show, createMemo, createEffect, type Component } from "solid-js" +import { Show, createMemo, createEffect, createSignal, type Component } from "solid-js" import type { Session } from "../../types/session" import type { Attachment } from "../../types/attachment" import type { ClientPart } from "../../types/message" @@ -26,18 +26,25 @@ interface SessionViewProps { showSidebarToggle?: boolean onSidebarToggle?: () => void forceCompactStatusLayout?: boolean + isActive: boolean } export const SessionView: Component = (props) => { const session = () => props.activeSessions.get(props.sessionId) const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId)) const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId)) + const [scrollToBottomHandle, setScrollToBottomHandle] = createSignal<(() => void) | null>(null) + createEffect(() => { + if (!props.isActive) return + const handler = scrollToBottomHandle() + if (!handler) return + requestAnimationFrame(() => requestAnimationFrame(() => handler())) + }) const sessionBusy = createMemo(() => { const currentSession = session() if (!currentSession) return false return getSessionBusyStatus(props.instanceId, currentSession.id) }) - let scrollToBottomHandle: (() => void) | undefined let quoteHandler: ((text: string, mode: "quote" | "code") => void) | null = null createEffect(() => { @@ -63,9 +70,9 @@ export const SessionView: Component = (props) => { } async function handleSendMessage(prompt: string, attachments: Attachment[]) { - - if (scrollToBottomHandle) { - scrollToBottomHandle() + const handler = scrollToBottomHandle() + if (handler) { + handler() } await sendMessage(props.instanceId, props.sessionId, prompt, attachments) } @@ -193,9 +200,16 @@ export const SessionView: Component = (props) => { loading={messagesLoading()} onRevert={handleRevert} onFork={handleFork} + isActive={props.isActive} registerScrollToBottom={(fn) => { - scrollToBottomHandle = fn + setScrollToBottomHandle(() => fn) + if (props.isActive) { + requestAnimationFrame(() => requestAnimationFrame(() => fn())) + } }} + + + showSidebarToggle={props.showSidebarToggle} onSidebarToggle={props.onSidebarToggle} forceCompactStatusLayout={props.forceCompactStatusLayout} diff --git a/packages/ui/src/components/virtual-item.tsx b/packages/ui/src/components/virtual-item.tsx index 3537d0f1..58e187c7 100644 --- a/packages/ui/src/components/virtual-item.tsx +++ b/packages/ui/src/components/virtual-item.tsx @@ -227,6 +227,7 @@ export default function VirtualItem(props: VirtualItemProps) { cleanupResizeObserver() } } + createEffect(() => { if (shouldHideContent()) { @@ -283,9 +284,6 @@ export default function VirtualItem(props: VirtualItemProps) { const placeholderClass = () => ["virtual-item-placeholder", props.placeholderClass].filter(Boolean).join(" ") const lazyContent = createMemo(() => { if (shouldHideContent()) return null - if (import.meta.env?.DEV) { - console.debug("rendering virtual item", props.cacheKey) - } return resolved() }) diff --git a/packages/ui/src/stores/message-v2/bus.ts b/packages/ui/src/stores/message-v2/bus.ts index 45250647..2115d7da 100644 --- a/packages/ui/src/stores/message-v2/bus.ts +++ b/packages/ui/src/stores/message-v2/bus.ts @@ -8,17 +8,39 @@ const log = getLogger("session") class MessageStoreBus { private stores = new Map() private teardownHandlers = new Set<(instanceId: string) => void>() + private sessionClearHandlers = new Set<(instanceId: string, sessionId: string) => void>() registerInstance(instanceId: string, store?: InstanceMessageStore): InstanceMessageStore { if (this.stores.has(instanceId)) { return this.stores.get(instanceId) as InstanceMessageStore } - const resolved = store ?? createInstanceMessageStore(instanceId) + const resolved = + store ?? + createInstanceMessageStore(instanceId, { + onSessionCleared: (id, sessionId) => this.notifySessionCleared(id, sessionId), + }) this.stores.set(instanceId, resolved) return resolved } + onSessionCleared(handler: (instanceId: string, sessionId: string) => void): () => void { + this.sessionClearHandlers.add(handler) + return () => { + this.sessionClearHandlers.delete(handler) + } + } + + private notifySessionCleared(instanceId: string, sessionId: string) { + for (const handler of this.sessionClearHandlers) { + try { + handler(instanceId, sessionId) + } catch (error) { + log.error("Failed to run session clear handler", error) + } + } + } + getInstance(instanceId: string): InstanceMessageStore | undefined { return this.stores.get(instanceId) } diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 693af550..0bc681f1 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -1,7 +1,9 @@ import { batch } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import type { SetStoreFunction } from "solid-js/store" +import { getLogger } from "../../lib/logger" import type { ClientPart, MessageInfo } from "../../types/message" +import { clearRecordDisplayCacheForMessages } from "./record-display-cache" import type { InstanceMessageState, MessageRecord, @@ -17,6 +19,12 @@ import type { UsageEntry, } from "./types" +const storeLog = getLogger("session") + +interface MessageStoreHooks { + onSessionCleared?: (instanceId: string, sessionId: string) => void +} + function createInitialState(instanceId: string): InstanceMessageState { return { instanceId, @@ -202,7 +210,7 @@ export interface InstanceMessageStore { clearInstance: () => void } -export function createInstanceMessageStore(instanceId: string): InstanceMessageStore { +export function createInstanceMessageStore(instanceId: string, hooks?: MessageStoreHooks): InstanceMessageStore { const [state, setState] = createStore(createInitialState(instanceId)) @@ -696,80 +704,92 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS function clearSession(sessionId: string) { if (!sessionId) return - const messageIds = Object.values(state.messages) - .filter((record) => record.sessionId === sessionId) - .map((record) => record.id) + const messageIds = Object.values(state.messages) + .filter((record) => record.sessionId === sessionId) + .map((record) => record.id) + + storeLog.info("Clearing session data", { instanceId, sessionId, messageCount: messageIds.length }) + clearRecordDisplayCacheForMessages(instanceId, messageIds) + + batch(() => { + setState("messages", (prev) => { + const next = { ...prev } + messageIds.forEach((id) => delete next[id]) + return next + }) - // Remove message-level data - setState("messages", (prev) => { - const next = { ...prev } - messageIds.forEach((id) => delete next[id]) - return next - }) + setState("messageInfoVersion", (prev) => { + const next = { ...prev } + messageIds.forEach((id) => delete next[id]) + return next + }) - setState("messageInfoVersion", (prev) => { - const next = { ...prev } - messageIds.forEach((id) => delete next[id]) - return next - }) + messageIds.forEach((id) => messageInfoCache.delete(id)) - messageIds.forEach((id) => messageInfoCache.delete(id)) + setState("pendingParts", (prev) => { + const next = { ...prev } + messageIds.forEach((id) => { + if (next[id]) delete next[id] + }) + return next + }) - setState("pendingParts", (prev) => { - const next = { ...prev } - messageIds.forEach((id) => { - if (next[id]) delete next[id] - }) - return next - }) + setState("permissions", "byMessage", (prev) => { + const next = { ...prev } + messageIds.forEach((id) => { + if (next[id]) delete next[id] + }) + return next + }) - setState("permissions", "byMessage", (prev) => { - const next = { ...prev } - messageIds.forEach((id) => { - if (next[id]) delete next[id] - }) - return next - }) + setState("usage", (prev) => { + const next = { ...prev } + delete next[sessionId] + return next + }) - // Remove session-level data - setState("usage", (prev) => { - const next = { ...prev } - delete next[sessionId] - return next - }) + setState("sessionRevisions", (prev) => { + const next = { ...prev } + delete next[sessionId] + return next + }) - setState("sessionRevisions", (prev) => { - const next = { ...prev } - delete next[sessionId] - return next - }) + setState("scrollState", (prev) => { + const next = { ...prev } + const prefix = `${sessionId}:` + Object.keys(next).forEach((key) => { + if (key.startsWith(prefix)) { + delete next[key] + } + }) + return next + }) - setState("scrollState", (prev) => { - const next = { ...prev } - const prefix = `${sessionId}:` - Object.keys(next).forEach((key) => { - if (key.startsWith(prefix)) { - delete next[key] - } - }) - return next - }) + setState("sessions", sessionId, (current) => { + if (!current) return current + return { ...current, messageIds: [] } + }) - setState("sessions", (prev) => { - const next = { ...prev } - delete next[sessionId] - return next - }) + setState("sessions", (prev) => { + const next = { ...prev } + delete next[sessionId] + return next + }) - setState("sessionOrder", (ids) => ids.filter((id) => id !== sessionId)) - } + setState("sessionOrder", (ids) => ids.filter((id) => id !== sessionId)) + }) + + hooks?.onSessionCleared?.(instanceId, sessionId) + } + function clearInstance() { messageInfoCache.clear() setState(reconcile(createInitialState(instanceId))) } return { + instanceId, state, setState, diff --git a/packages/ui/src/stores/message-v2/record-display-cache.ts b/packages/ui/src/stores/message-v2/record-display-cache.ts index b653e4f0..4e9c6c5b 100644 --- a/packages/ui/src/stores/message-v2/record-display-cache.ts +++ b/packages/ui/src/stores/message-v2/record-display-cache.ts @@ -44,3 +44,10 @@ export function clearRecordDisplayCacheForInstance(instanceId: string) { } } } + +export function clearRecordDisplayCacheForMessages(instanceId: string, messageIds: Iterable) { + for (const messageId of messageIds) { + if (typeof messageId !== "string" || messageId.length === 0) continue + recordDisplayCache.delete(makeCacheKey(instanceId, messageId)) + } +} diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index 452c9354..78697b0a 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -39,7 +39,31 @@ const [loading, setLoading] = createSignal({ const [messagesLoaded, setMessagesLoaded] = createSignal>>(new Map()) const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal>>(new Map()) +function clearLoadedFlag(instanceId: string, sessionId: string) { + if (!instanceId || !sessionId) return + setMessagesLoaded((prev) => { + const existing = prev.get(instanceId) + if (!existing || !existing.has(sessionId)) { + return prev + } + const next = new Map(prev) + const updated = new Set(existing) + updated.delete(sessionId) + if (updated.size === 0) { + next.delete(instanceId) + } else { + next.set(instanceId, updated) + } + return next + }) +} + +messageStoreBus.onSessionCleared((instanceId, sessionId) => { + clearLoadedFlag(instanceId, sessionId) +}) + function getDraftKey(instanceId: string, sessionId: string): string { + return `${instanceId}:${sessionId}` } @@ -357,8 +381,9 @@ export { setSessionCompactionState, setSessionPendingPermission, setActiveSession, - + setActiveParentSession, + clearActiveParentSession, getActiveSession, getActiveParentSession, diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index d50350ea..49b7787f 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -26,7 +26,8 @@ import { setActiveParentSession, setActiveSession, setSessionDraftPrompt, -} from "./session-state" + } from "./session-state" + import { getDefaultModel } from "./session-models" import { createSession,