perf(ui): reduce session list churn and message block invalidation

This commit is contained in:
Shantur Rathore
2026-01-12 16:37:09 +00:00
parent 72f420b6f6
commit 927e4e1281
5 changed files with 71 additions and 23 deletions

View File

@@ -875,7 +875,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="session-sidebar flex flex-col flex-1 min-h-0">
<SessionList
instanceId={props.instance.id}
sessions={allInstanceSessions()}
threads={sessionThreads()}
activeSessionId={activeSessionIdForInstance()}
onSelect={handleSessionSelect}

View File

@@ -1,4 +1,4 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { FoldVertical } from "lucide-solid"
import MessageItem from "./message-item"
import ToolCall from "./tool-call"
@@ -235,16 +235,11 @@ export default function MessageBlock(props: MessageBlockProps) {
const index = props.messageIndex
const lastAssistantIdx = props.lastAssistantIndex()
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
const info = messageInfo()
const infoTime = (info?.time ?? {}) as { created?: number; updated?: number; completed?: number }
const infoTimestamp =
typeof infoTime.completed === "number"
? infoTime.completed
: typeof infoTime.updated === "number"
? infoTime.updated
: infoTime.created ?? 0
const infoError = (info as { error?: { name?: string } } | undefined)?.error
const infoErrorName = typeof infoError?.name === "string" ? infoError.name : ""
// Intentionally untracked: messageInfoVersion updates should not trigger
// a full message block rebuild; record revision is the invalidation key.
const info = untrack(messageInfo)
const cacheSignature = [
current.id,
current.revision,
@@ -252,8 +247,6 @@ export default function MessageBlock(props: MessageBlockProps) {
props.showThinking() ? 1 : 0,
props.thinkingDefaultExpanded() ? 1 : 0,
props.showUsageMetrics() ? 1 : 0,
infoTimestamp,
infoErrorName,
].join("|")
const cachedBlock = sessionCache.messageBlocks.get(current.id)

View File

@@ -1,5 +1,5 @@
import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
import type { Session, SessionStatus } from "../types/session"
import type { SessionStatus } from "../types/session"
import type { SessionThread } from "../stores/session-state"
import { getSessionStatus } from "../stores/session-status"
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown } from "lucide-solid"
@@ -14,6 +14,7 @@ import {
isSessionParentExpanded,
loading,
renameSession,
sessions as sessionStateSessions,
setActiveSessionFromList,
toggleSessionParentExpanded,
} from "../stores/sessions"
@@ -25,7 +26,6 @@ const log = getLogger("session")
interface SessionListProps {
instanceId: string
sessions: Map<string, Session>
threads: SessionThread[]
activeSessionId: string | null
onSelect: (sessionId: string) => void
@@ -58,7 +58,7 @@ const SessionList: Component<SessionListProps> = (props) => {
const selectSession = (sessionId: string) => {
const session = props.sessions.get(sessionId)
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
const parentId = session?.parentId ?? session?.id
if (parentId) {
ensureSessionParentExpanded(props.instanceId, parentId)
@@ -132,7 +132,7 @@ const SessionList: Component<SessionListProps> = (props) => {
}
const openRenameDialog = (sessionId: string) => {
const session = props.sessions.get(sessionId)
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
if (!session) return
const label = session.title && session.title.trim() ? session.title : sessionId
setRenameTarget({ id: sessionId, title: session.title ?? "", label })
@@ -167,7 +167,7 @@ const SessionList: Component<SessionListProps> = (props) => {
expanded?: boolean
onToggleExpand?: () => void
}> = (rowProps) => {
const session = () => props.sessions.get(rowProps.sessionId)
const session = createMemo(() => sessionStateSessions().get(props.instanceId)?.get(rowProps.sessionId))
if (!session()) {
return <></>
}
@@ -293,7 +293,7 @@ const SessionList: Component<SessionListProps> = (props) => {
const activeId = props.activeSessionId
if (!activeId || activeId === "info") return null
const activeSession = props.sessions.get(activeId)
const activeSession = sessionStateSessions().get(props.instanceId)?.get(activeId)
if (!activeSession) return null
return activeSession.parentId ?? activeSession.id

View File

@@ -240,8 +240,20 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
const messageId = typeof info.id === "string" ? info.id : undefined
if (!sessionId || !messageId) return
const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; completed?: number }
const nextUpdated =
typeof timeInfo.completed === "number" && timeInfo.completed > 0
? timeInfo.completed
: typeof timeInfo.updated === "number" && timeInfo.updated > 0
? timeInfo.updated
: typeof timeInfo.created === "number" && timeInfo.created > 0
? timeInfo.created
: Date.now()
withSession(instanceId, sessionId, (session) => {
session.time = { ...(session.time ?? {}), updated: Date.now() }
const currentUpdated = session.time?.updated ?? 0
if (nextUpdated <= currentUpdated) return false
session.time = { ...(session.time ?? {}), updated: nextUpdated }
})
const store = messageStoreBus.getOrCreate(instanceId)

View File

@@ -390,9 +390,35 @@ function getSessionFamily(instanceId: string, parentId: string): Session[] {
return [parent, ...children]
}
type SessionThreadCacheEntry = {
signature: string
thread: SessionThread
}
type SessionThreadCache = {
byParentId: Map<string, SessionThreadCacheEntry>
}
const sessionThreadCache = new Map<string, SessionThreadCache>()
function getOrCreateSessionThreadCache(instanceId: string): SessionThreadCache {
let cache = sessionThreadCache.get(instanceId)
if (!cache) {
cache = { byParentId: new Map() }
sessionThreadCache.set(instanceId, cache)
}
return cache
}
function getSessionThreads(instanceId: string): SessionThread[] {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions || instanceSessions.size === 0) return []
if (!instanceSessions || instanceSessions.size === 0) {
sessionThreadCache.delete(instanceId)
return []
}
const cache = getOrCreateSessionThreadCache(instanceId)
const seenParents = new Set<string>()
const parents: Session[] = []
const childrenByParent = new Map<string, Session[]>()
@@ -416,6 +442,8 @@ function getSessionThreads(instanceId: string): SessionThread[] {
const threads: SessionThread[] = []
for (const parent of parents) {
seenParents.add(parent.id)
const children = childrenByParent.get(parent.id) ?? []
if (children.length > 1) {
children.sort((a, b) => (b.time.updated ?? 0) - (a.time.updated ?? 0))
@@ -425,7 +453,23 @@ function getSessionThreads(instanceId: string): SessionThread[] {
const latestChild = children[0]?.time.updated ?? 0
const latestUpdated = Math.max(parentUpdated, latestChild)
threads.push({ parent, children, latestUpdated })
const childIds = children.map((child) => child.id).join(",")
const signature = `${parentUpdated}:${latestChild}:${childIds}`
const cached = cache.byParentId.get(parent.id)
if (cached && cached.signature === signature) {
threads.push(cached.thread)
} else {
const thread: SessionThread = { parent, children, latestUpdated }
cache.byParentId.set(parent.id, { signature, thread })
threads.push(thread)
}
}
for (const parentId of Array.from(cache.byParentId.keys())) {
if (!seenParents.has(parentId)) {
cache.byParentId.delete(parentId)
}
}
threads.sort((a, b) => {