Improve session cache eviction
This commit is contained in:
@@ -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>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()}>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user