Improve session cache eviction

This commit is contained in:
Shantur Rathore
2025-12-09 14:05:10 +00:00
parent e54f80f20e
commit 8204143810
10 changed files with 319 additions and 100 deletions

View File

@@ -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 { Accessor } from "solid-js"
import type { Instance } from "../../types/instance" import type { Instance } from "../../types/instance"
import type { Command } from "../../lib/commands" import type { Command } from "../../lib/commands"
import { activeParentSessionId, activeSessionId as activeSessionMap, getSessionFamily, setActiveSession } from "../../stores/sessions" import { activeParentSessionId, activeSessionId as activeSessionMap, getSessionFamily, setActiveSession } from "../../stores/sessions"
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry" 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 { buildCustomCommandEntries } from "../../lib/command-utils"
import { getCommands as getInstanceCommands } from "../../stores/commands" import { getCommands as getInstanceCommands } from "../../stores/commands"
import { isOpen as isCommandPaletteOpen, hideCommandPalette } from "../../stores/command-palette" import { isOpen as isCommandPaletteOpen, hideCommandPalette } from "../../stores/command-palette"
@@ -34,11 +36,14 @@ interface InstanceShellProps {
const DEFAULT_SESSION_SIDEBAR_WIDTH = 350 const DEFAULT_SESSION_SIDEBAR_WIDTH = 350
const MOBILE_SIDEBAR_BREAKPOINT = 1024 const MOBILE_SIDEBAR_BREAKPOINT = 1024
const SESSION_CACHE_LIMIT = 2
const InstanceShell: Component<InstanceShellProps> = (props) => { const InstanceShell: Component<InstanceShellProps> = (props) => {
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH) const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
const [isCompactLayout, setIsCompactLayout] = createSignal(false) const [isCompactLayout, setIsCompactLayout] = createSignal(false)
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true) const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
const [cachedSessionIds, setCachedSessionIds] = createSignal<string[]>([])
const [pendingEvictions, setPendingEvictions] = createSignal<string[]>([])
const sidebarId = `session-sidebar-${props.instance.id}` const sidebarId = `session-sidebar-${props.instance.id}`
let previousIsCompact = false let previousIsCompact = false
@@ -77,12 +82,17 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
return activeSessionMap().get(props.instance.id) || null return activeSessionMap().get(props.instance.id) || null
}) })
const parentSessionIdForInstance = createMemo(() => {
return activeParentSessionId().get(props.instance.id) || null
})
const activeSessionForInstance = createMemo(() => { const activeSessionForInstance = createMemo(() => {
const sessionId = activeSessionIdForInstance() const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") return null if (!sessionId || sessionId === "info") return null
return activeSessions().get(sessionId) ?? null return activeSessions().get(sessionId) ?? null
}) })
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id))) const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()]) const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id)) const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id))
@@ -97,6 +107,74 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
setActiveSession(props.instance.id, sessionId) 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 ( return (
<> <>
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={props.instance} />}> <Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={props.instance} />}>
@@ -212,8 +290,7 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
when={activeSessionIdForInstance() === "info"} when={activeSessionIdForInstance() === "info"}
fallback={ fallback={
<Show <Show
when={activeSessionIdForInstance()} when={cachedSessionIds().length > 0 && activeSessionIdForInstance()}
keyed
fallback={ fallback={
<div class="flex items-center justify-center h-full"> <div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500 dark:text-gray-400"> <div class="text-center text-gray-500 dark:text-gray-400">
@@ -223,18 +300,31 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
</div> </div>
} }
> >
{(sessionId) => ( <For each={cachedSessionIds()}>
<SessionView {(sessionId) => {
sessionId={sessionId} const isActive = () => activeSessionIdForInstance() === sessionId
activeSessions={activeSessions()} return (
instanceId={props.instance.id} <div
instanceFolder={props.instance.folder} class="session-cache-pane flex flex-col flex-1 min-h-0"
escapeInDebounce={props.escapeInDebounce} style={{ display: isActive() ? "flex" : "none" }}
showSidebarToggle={shouldShowSidebarToggle()} data-session-id={sessionId}
onSidebarToggle={() => setIsSidebarOpen(true)} aria-hidden={!isActive()}
forceCompactStatusLayout={shouldShowSidebarToggle()} >
/> <SessionView
)} sessionId={sessionId}
activeSessions={activeSessions()}
instanceId={props.instance.id}
instanceFolder={props.instance.folder}
escapeInDebounce={props.escapeInDebounce}
showSidebarToggle={shouldShowSidebarToggle()}
onSidebarToggle={() => setIsSidebarOpen(true)}
forceCompactStatusLayout={shouldShowSidebarToggle()}
isActive={isActive()}
/>
</div>
)
}}
</For>
</Show> </Show>
} }
> >

View File

@@ -125,6 +125,10 @@ function makeSessionCacheKey(instanceId: string, sessionId: string) {
return `${instanceId}:${sessionId}` return `${instanceId}:${sessionId}`
} }
export function clearSessionRenderCache(instanceId: string, sessionId: string) {
renderCaches.delete(makeSessionCacheKey(instanceId, sessionId))
}
function getSessionRenderCache(instanceId: string, sessionId: string): SessionRenderCache { function getSessionRenderCache(instanceId: string, sessionId: string): SessionRenderCache {
const key = makeSessionCacheKey(instanceId, sessionId) const key = makeSessionCacheKey(instanceId, sessionId)
let cache = renderCaches.get(key) let cache = renderCaches.get(key)

View File

@@ -30,6 +30,8 @@ export interface MessageSectionProps {
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
registerScrollToBottom?: (fn: () => void) => void registerScrollToBottom?: (fn: () => void) => void
requestScrollToBottom?: () => void
isActive: boolean
showSidebarToggle?: boolean showSidebarToggle?: boolean
onSidebarToggle?: () => void onSidebarToggle?: () => void
forceCompactStatusLayout?: boolean forceCompactStatusLayout?: boolean
@@ -134,7 +136,12 @@ export default function MessageSection(props: MessageSectionProps) {
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>() const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
const [topSentinel, setTopSentinel] = createSignal<HTMLDivElement | null>(null) const [topSentinel, setTopSentinel] = createSignal<HTMLDivElement | null>(null)
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null) const [bottomSentinelSignal, setBottomSentinelSignal] = createSignal<HTMLDivElement | null>(null)
const bottomSentinel = () => bottomSentinelSignal()
const setBottomSentinel = (element: HTMLDivElement | null) => {
setBottomSentinelSignal(element)
}
const [scrollToBottomRequest, setScrollToBottomRequest] = createSignal(false)
const [autoScroll, setAutoScroll] = createSignal(true) const [autoScroll, setAutoScroll] = createSignal(true)
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
@@ -190,6 +197,9 @@ export default function MessageSection(props: MessageSectionProps) {
function setContainerRef(element: HTMLDivElement | null) { function setContainerRef(element: HTMLDivElement | null) {
containerRef = element || undefined containerRef = element || undefined
if (import.meta.env?.DEV) {
console.debug("[MessageSection] setContainerRef", props.sessionId, Boolean(containerRef))
}
setScrollElement(containerRef) setScrollElement(containerRef)
attachScrollIntentListeners(containerRef) attachScrollIntentListeners(containerRef)
if (!containerRef) { if (!containerRef) {
@@ -207,8 +217,13 @@ export default function MessageSection(props: MessageSectionProps) {
function updateScrollIndicatorsFromVisibility() { function updateScrollIndicatorsFromVisibility() {
const hasItems = messageIds().length > 0 const hasItems = messageIds().length > 0
setShowScrollBottomButton(hasItems && !bottomSentinelVisible()) const bottomVisible = bottomSentinelVisible()
setShowScrollTopButton(hasItems && !topSentinelVisible()) 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() { function scheduleScrollPersist() {
@@ -216,13 +231,20 @@ export default function MessageSection(props: MessageSectionProps) {
pendingScrollPersist = requestAnimationFrame(() => { pendingScrollPersist = requestAnimationFrame(() => {
pendingScrollPersist = null pendingScrollPersist = null
if (!containerRef) return if (!containerRef) return
scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX }) // scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX })
}) })
} }
function scrollToBottom(immediate = false) { function scrollToBottom(immediate = false) {
if (!containerRef) return if (!containerRef) return
const sentinel = bottomSentinel() 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" const behavior = immediate ? "auto" : "smooth"
if (!immediate) { if (!immediate) {
suppressAutoScrollOnce = true suppressAutoScrollOnce = true
@@ -235,6 +257,9 @@ export default function MessageSection(props: MessageSectionProps) {
function scrollToTop(immediate = false) { function scrollToTop(immediate = false) {
if (!containerRef) return if (!containerRef) return
const behavior = immediate ? "auto" : "smooth" const behavior = immediate ? "auto" : "smooth"
if (import.meta.env?.DEV) {
console.debug("[MessageSection] scrollToTop", props.sessionId, { immediate })
}
setAutoScroll(false) setAutoScroll(false)
topSentinel()?.scrollIntoView({ block: "start", inline: "nearest", behavior }) topSentinel()?.scrollIntoView({ block: "start", inline: "nearest", behavior })
scheduleScrollPersist() 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(() => { createEffect(() => {
if (!props.onQuoteSelection) { if (!props.onQuoteSelection) {
clearQuoteSelection() clearQuoteSelection()
@@ -392,16 +428,16 @@ export default function MessageSection(props: MessageSectionProps) {
if (!target || loading || hasRestoredScroll) return if (!target || loading || hasRestoredScroll) return
scrollCache.restore(target, { // scrollCache.restore(target, {
onApplied: (snapshot) => { // onApplied: (snapshot) => {
if (snapshot) { // if (snapshot) {
setAutoScroll(snapshot.atBottom) // setAutoScroll(snapshot.atBottom)
} else { // } else {
setAutoScroll(bottomSentinelVisible()) // setAutoScroll(bottomSentinelVisible())
} // }
updateScrollIndicatorsFromVisibility() // updateScrollIndicatorsFromVisibility()
}, // },
}) // })
hasRestoredScroll = true hasRestoredScroll = true
}) })
@@ -522,7 +558,7 @@ export default function MessageSection(props: MessageSectionProps) {
detachScrollIntentListeners() detachScrollIntentListeners()
} }
if (containerRef) { if (containerRef) {
scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX }) // scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX })
} }
clearQuoteSelection() clearQuoteSelection()
}) })
@@ -591,6 +627,8 @@ export default function MessageSection(props: MessageSectionProps) {
onContentRendered={handleContentRendered} onContentRendered={handleContentRendered}
setBottomSentinel={setBottomSentinel} setBottomSentinel={setBottomSentinel}
/> />
</div> </div>
<Show when={showScrollTopButton() || showScrollBottomButton()}> <Show when={showScrollTopButton() || showScrollBottomButton()}>

View File

@@ -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 { Session } from "../../types/session"
import type { Attachment } from "../../types/attachment" import type { Attachment } from "../../types/attachment"
import type { ClientPart } from "../../types/message" import type { ClientPart } from "../../types/message"
@@ -26,18 +26,25 @@ interface SessionViewProps {
showSidebarToggle?: boolean showSidebarToggle?: boolean
onSidebarToggle?: () => void onSidebarToggle?: () => void
forceCompactStatusLayout?: boolean forceCompactStatusLayout?: boolean
isActive: boolean
} }
export const SessionView: Component<SessionViewProps> = (props) => { export const SessionView: Component<SessionViewProps> = (props) => {
const session = () => props.activeSessions.get(props.sessionId) const session = () => props.activeSessions.get(props.sessionId)
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId)) const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId)) 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 sessionBusy = createMemo(() => {
const currentSession = session() const currentSession = session()
if (!currentSession) return false if (!currentSession) return false
return getSessionBusyStatus(props.instanceId, currentSession.id) return getSessionBusyStatus(props.instanceId, currentSession.id)
}) })
let scrollToBottomHandle: (() => void) | undefined
let quoteHandler: ((text: string, mode: "quote" | "code") => void) | null = null let quoteHandler: ((text: string, mode: "quote" | "code") => void) | null = null
createEffect(() => { createEffect(() => {
@@ -63,9 +70,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
} }
async function handleSendMessage(prompt: string, attachments: Attachment[]) { async function handleSendMessage(prompt: string, attachments: Attachment[]) {
const handler = scrollToBottomHandle()
if (scrollToBottomHandle) { if (handler) {
scrollToBottomHandle() handler()
} }
await sendMessage(props.instanceId, props.sessionId, prompt, attachments) await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
} }
@@ -193,9 +200,16 @@ export const SessionView: Component<SessionViewProps> = (props) => {
loading={messagesLoading()} loading={messagesLoading()}
onRevert={handleRevert} onRevert={handleRevert}
onFork={handleFork} onFork={handleFork}
isActive={props.isActive}
registerScrollToBottom={(fn) => { registerScrollToBottom={(fn) => {
scrollToBottomHandle = fn setScrollToBottomHandle(() => fn)
if (props.isActive) {
requestAnimationFrame(() => requestAnimationFrame(() => fn()))
}
}} }}
showSidebarToggle={props.showSidebarToggle} showSidebarToggle={props.showSidebarToggle}
onSidebarToggle={props.onSidebarToggle} onSidebarToggle={props.onSidebarToggle}
forceCompactStatusLayout={props.forceCompactStatusLayout} forceCompactStatusLayout={props.forceCompactStatusLayout}

View File

@@ -227,6 +227,7 @@ export default function VirtualItem(props: VirtualItemProps) {
cleanupResizeObserver() cleanupResizeObserver()
} }
} }
createEffect(() => { createEffect(() => {
if (shouldHideContent()) { if (shouldHideContent()) {
@@ -283,9 +284,6 @@ export default function VirtualItem(props: VirtualItemProps) {
const placeholderClass = () => ["virtual-item-placeholder", props.placeholderClass].filter(Boolean).join(" ") const placeholderClass = () => ["virtual-item-placeholder", props.placeholderClass].filter(Boolean).join(" ")
const lazyContent = createMemo<JSX.Element | null>(() => { const lazyContent = createMemo<JSX.Element | null>(() => {
if (shouldHideContent()) return null if (shouldHideContent()) return null
if (import.meta.env?.DEV) {
console.debug("rendering virtual item", props.cacheKey)
}
return resolved() return resolved()
}) })

View File

@@ -8,17 +8,39 @@ const log = getLogger("session")
class MessageStoreBus { class MessageStoreBus {
private stores = new Map<string, InstanceMessageStore>() private stores = new Map<string, InstanceMessageStore>()
private teardownHandlers = new Set<(instanceId: string) => void>() private teardownHandlers = new Set<(instanceId: string) => void>()
private sessionClearHandlers = new Set<(instanceId: string, sessionId: string) => void>()
registerInstance(instanceId: string, store?: InstanceMessageStore): InstanceMessageStore { registerInstance(instanceId: string, store?: InstanceMessageStore): InstanceMessageStore {
if (this.stores.has(instanceId)) { if (this.stores.has(instanceId)) {
return this.stores.get(instanceId) as InstanceMessageStore 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) this.stores.set(instanceId, resolved)
return 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 { getInstance(instanceId: string): InstanceMessageStore | undefined {
return this.stores.get(instanceId) return this.stores.get(instanceId)
} }

View File

@@ -1,7 +1,9 @@
import { batch } from "solid-js" import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import type { SetStoreFunction } from "solid-js/store" import type { SetStoreFunction } from "solid-js/store"
import { getLogger } from "../../lib/logger"
import type { ClientPart, MessageInfo } from "../../types/message" import type { ClientPart, MessageInfo } from "../../types/message"
import { clearRecordDisplayCacheForMessages } from "./record-display-cache"
import type { import type {
InstanceMessageState, InstanceMessageState,
MessageRecord, MessageRecord,
@@ -17,6 +19,12 @@ import type {
UsageEntry, UsageEntry,
} from "./types" } from "./types"
const storeLog = getLogger("session")
interface MessageStoreHooks {
onSessionCleared?: (instanceId: string, sessionId: string) => void
}
function createInitialState(instanceId: string): InstanceMessageState { function createInitialState(instanceId: string): InstanceMessageState {
return { return {
instanceId, instanceId,
@@ -202,7 +210,7 @@ export interface InstanceMessageStore {
clearInstance: () => void clearInstance: () => void
} }
export function createInstanceMessageStore(instanceId: string): InstanceMessageStore { export function createInstanceMessageStore(instanceId: string, hooks?: MessageStoreHooks): InstanceMessageStore {
const [state, setState] = createStore<InstanceMessageState>(createInitialState(instanceId)) const [state, setState] = createStore<InstanceMessageState>(createInitialState(instanceId))
@@ -696,80 +704,92 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
function clearSession(sessionId: string) { function clearSession(sessionId: string) {
if (!sessionId) return if (!sessionId) return
const messageIds = Object.values(state.messages) const messageIds = Object.values(state.messages)
.filter((record) => record.sessionId === sessionId) .filter((record) => record.sessionId === sessionId)
.map((record) => record.id) .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("messageInfoVersion", (prev) => {
setState("messages", (prev) => { const next = { ...prev }
const next = { ...prev } messageIds.forEach((id) => delete next[id])
messageIds.forEach((id) => delete next[id]) return next
return next })
})
setState("messageInfoVersion", (prev) => { messageIds.forEach((id) => messageInfoCache.delete(id))
const next = { ...prev }
messageIds.forEach((id) => delete next[id])
return next
})
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) => { setState("permissions", "byMessage", (prev) => {
const next = { ...prev } const next = { ...prev }
messageIds.forEach((id) => { messageIds.forEach((id) => {
if (next[id]) delete next[id] if (next[id]) delete next[id]
}) })
return next return next
}) })
setState("permissions", "byMessage", (prev) => { setState("usage", (prev) => {
const next = { ...prev } const next = { ...prev }
messageIds.forEach((id) => { delete next[sessionId]
if (next[id]) delete next[id] return next
}) })
return next
})
// Remove session-level data setState("sessionRevisions", (prev) => {
setState("usage", (prev) => { const next = { ...prev }
const next = { ...prev } delete next[sessionId]
delete next[sessionId] return next
return next })
})
setState("sessionRevisions", (prev) => { setState("scrollState", (prev) => {
const next = { ...prev } const next = { ...prev }
delete next[sessionId] const prefix = `${sessionId}:`
return next Object.keys(next).forEach((key) => {
}) if (key.startsWith(prefix)) {
delete next[key]
}
})
return next
})
setState("scrollState", (prev) => { setState("sessions", sessionId, (current) => {
const next = { ...prev } if (!current) return current
const prefix = `${sessionId}:` return { ...current, messageIds: [] }
Object.keys(next).forEach((key) => { })
if (key.startsWith(prefix)) {
delete next[key]
}
})
return next
})
setState("sessions", (prev) => { setState("sessions", (prev) => {
const next = { ...prev } const next = { ...prev }
delete next[sessionId] delete next[sessionId]
return next return next
}) })
setState("sessionOrder", (ids) => ids.filter((id) => id !== sessionId)) setState("sessionOrder", (ids) => ids.filter((id) => id !== sessionId))
} })
hooks?.onSessionCleared?.(instanceId, sessionId)
}
function clearInstance() { function clearInstance() {
messageInfoCache.clear() messageInfoCache.clear()
setState(reconcile(createInitialState(instanceId))) setState(reconcile(createInitialState(instanceId)))
} }
return { return {
instanceId, instanceId,
state, state,
setState, setState,

View File

@@ -44,3 +44,10 @@ export function clearRecordDisplayCacheForInstance(instanceId: string) {
} }
} }
} }
export function clearRecordDisplayCacheForMessages(instanceId: string, messageIds: Iterable<string>) {
for (const messageId of messageIds) {
if (typeof messageId !== "string" || messageId.length === 0) continue
recordDisplayCache.delete(makeCacheKey(instanceId, messageId))
}
}

View File

@@ -39,7 +39,31 @@ const [loading, setLoading] = createSignal({
const [messagesLoaded, setMessagesLoaded] = createSignal<Map<string, Set<string>>>(new Map()) const [messagesLoaded, setMessagesLoaded] = createSignal<Map<string, Set<string>>>(new Map())
const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal<Map<string, Map<string, SessionInfo>>>(new Map()) const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal<Map<string, Map<string, SessionInfo>>>(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 { function getDraftKey(instanceId: string, sessionId: string): string {
return `${instanceId}:${sessionId}` return `${instanceId}:${sessionId}`
} }
@@ -357,8 +381,9 @@ export {
setSessionCompactionState, setSessionCompactionState,
setSessionPendingPermission, setSessionPendingPermission,
setActiveSession, setActiveSession,
setActiveParentSession, setActiveParentSession,
clearActiveParentSession, clearActiveParentSession,
getActiveSession, getActiveSession,
getActiveParentSession, getActiveParentSession,

View File

@@ -26,7 +26,8 @@ import {
setActiveParentSession, setActiveParentSession,
setActiveSession, setActiveSession,
setSessionDraftPrompt, setSessionDraftPrompt,
} from "./session-state" } from "./session-state"
import { getDefaultModel } from "./session-models" import { getDefaultModel } from "./session-models"
import { import {
createSession, createSession,