perf(sessions): cap warm session runtimes to bound /sessions memory growth

The /sessions sidebar eagerly warmed a full runtime (AIChatManager + loaded
chat history + an IndexedDB connection) for every visible session, so the
page's mounted weight grew linearly (O(number of non-archived in-family
sessions)) and never released as sessions accumulate over time.

Cap the warm runtimes with an MRU (the active session + up to
MAX_WARM_RUNTIMES) and dispose the rest, mirroring the existing preview-tab
and editor MRU caps. Sessions holding volatile state — a mid-stream
generation, a pending tool confirmation, or unsent composer text — are kept
warm so nothing is cancelled or lost (protect-not-persist; the composer draft
lives in AIChatInput local state and isn't persisted). Cold (evicted)
sessions show the neutral status dot with no unread badge until reopened.

Measured ~1.5-2x the transcript size per resident session; the change
converts O(N) growth into O(1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-07-07 16:04:00 +02:00
co-authored by Claude Opus 4.8
parent 95031903eb
commit 086a2c3e4b
5 changed files with 103 additions and 21 deletions
@@ -36,7 +36,6 @@
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import {
getOrCreateRuntime,
getRuntime,
getSessionChatStatus,
removeSession,
@@ -80,7 +79,9 @@
// read manager.instructions directly (not the derived chat status)
// so the draft cue still shows during streaming/needs-confirmation —
// those override the icon slot but shouldn't hide the fact that the
// user has unsent text in this session.
// user has unsent text in this session. A cold (evicted) session has
// no live runtime, so no cue — but a session with an unsent draft is
// kept warm (see evictColdRuntimes), so this stays accurate.
function hasDraft(session: Session): boolean {
const rt = getRuntime(session.id)
return !!rt && rt.manager.instructions.trim().length > 0
@@ -269,17 +270,12 @@
}
})
// Eagerly create a runtime per VISIBLE session so the status dot reflects
// the persisted chat (last message, pending confirmation, etc.) without
// requiring the user to open the session first. Sessions outside the
// current workspace scope are left cold to avoid opening IDB connections
// for unrelated work.
$effect(() => {
for (const session of visibleSessions) {
getOrCreateRuntime(session)
}
})
// The picker no longer eagerly warms a runtime per visible session — that made
// mounted weight grow unbounded with the family size. Runtimes are created on
// activation and MRU-capped (see sessionRuntime), so a cold session shows the
// neutral status dot and no unread badge until it's opened; sessions with
// volatile state (pending confirmation / unsent draft / streaming) are kept
// warm so their live dot stays correct.
function isUnavailableFork(session: Session): boolean {
return !!session.workspace_id && !$userWorkspaces.find((w) => w.id === session.workspace_id)
}
@@ -53,7 +53,12 @@
syncWorkspaceTo,
type SessionTarget
} from './sessionState.svelte'
import { editorWarmIds, getOrCreateRuntime, removeSession } from './sessionRuntime.svelte'
import {
editorWarmIds,
getOrCreateRuntime,
removeSession,
setSessionDraftFlag
} from './sessionRuntime.svelte'
import { goto } from '$lib/navigation'
import { base } from '$app/paths'
import { slide } from 'svelte/transition'
@@ -533,7 +538,13 @@
hideModeSelector
wideLayout
initialInstructions={restoredDraftPrompt}
onDraftChange={(text) => queueTransientDraftPrompt(sessionId, text)}
onDraftChange={(text) => {
queueTransientDraftPrompt(sessionId, text)
// Keep the runtime warm while the composer holds unsent text — the
// draft is local to AIChatInput and isn't persisted, so eviction
// would lose it (see setSessionDraftFlag).
setSessionDraftFlag(sessionId, text.trim().length > 0)
}}
forceDisabled={isUnavailable || !!session.archived}
forceDisabledMessage={isUnavailable
? 'This session is linked to a workspace that no longer exists. Move it or discard it from the banner above to keep working.'
@@ -738,6 +738,8 @@ export function disposeRuntime(sessionId: string) {
runtime.manager.cancel('runtime disposed')
runtime.manager.historyManager.close()
runtimes.delete(sessionId)
runtimeWarmIds.delete(sessionId)
sessionsWithDraft.delete(sessionId)
}
export function listRuntimes(): SessionRuntime[] {
@@ -800,6 +802,63 @@ export function promoteEditorWarm(sessionId: string): void {
}
}
// MRU of session ids whose runtime should stay warm. A full SessionRuntime
// (AIChatManager + loaded history + IDB connection) is ~tens of MB, so warming
// one per visible session made the page scale linearly with the family size.
// We keep the active session + this MRU + any session with an in-flight stream
// warm (see evictColdRuntimes) and dispose the rest; a cold session re-warms on
// activation from its persisted chat + snapshot.
const MAX_WARM_RUNTIMES = 3
const runtimeWarmIds = new SvelteSet<string>()
// Sessions whose composer holds unsent text. The draft lives in AIChatInput's
// local state (not manager.instructions — that only carries programmatic
// prompts), and it isn't persisted, so a session with a draft must be kept warm
// or the text is lost. SessionWrapper reports changes via setSessionDraftFlag as
// the user types; read at eviction time (see hasVolatileState).
const sessionsWithDraft = new Set<string>()
export function setSessionDraftFlag(sessionId: string, hasDraft: boolean): void {
if (hasDraft) sessionsWithDraft.add(sessionId)
else sessionsWithDraft.delete(sessionId)
}
export function promoteRuntimeWarm(sessionId: string): void {
runtimeWarmIds.delete(sessionId)
runtimeWarmIds.add(sessionId)
while (runtimeWarmIds.size > MAX_WARM_RUNTIMES) {
const oldest = runtimeWarmIds.values().next().value
if (oldest === undefined) break
runtimeWarmIds.delete(oldest)
}
}
// Live state that would be lost by eviction, so the runtime must stay warm even
// when it falls out of the MRU. We don't persist these (that machinery isn't
// worth its complexity — see the perf memo), so keeping the session warm is how
// its picker dot stays right and its unsent draft survives:
// - a mid-stream generation (disposeRuntime cancels it),
// - a pending tool confirmation (the amber "needs you" dot is read live),
// - unsent composer text (there is no persisted copy to restore from).
function hasVolatileState(runtime: SessionRuntime): boolean {
const m = runtime.manager
if (m.loading) return true
if (sessionsWithDraft.has(runtime.sessionId)) return true
const last = m.displayMessages[m.displayMessages.length - 1]
return last?.role === 'tool' && !!last.needsConfirmation
}
// Dispose every warm runtime that isn't the active session, in the warm MRU, or
// holding volatile state. Idle sessions that fall out of the MRU go cold; their
// picker dot reverts to the neutral icon until reopened.
export function evictColdRuntimes(activeId: string | undefined): void {
for (const runtime of runtimes.values()) {
const id = runtime.sessionId
if (id === activeId || runtimeWarmIds.has(id) || hasVolatileState(runtime)) continue
disposeRuntime(id)
}
}
// Register the global open_preview tool handler once at module load. It
// dispatches to the *calling* session (sessionId threaded from the tool ctx),
// falling back to the UI-active session only when none was passed — so a
@@ -18,7 +18,8 @@ export function markSessionSeen(sessionId: string, count: number) {
}
// Number of unread messages for a session. Undefined / unloaded runtime
// returns 0 — until messages are hydrated we don't know what's new.
// returns 0 — a cold (evicted / not-yet-warmed) session has no live message
// count to compare against, so its badge only appears once it's warmed.
export function unreadCountFor(sessionId: string, runtime: SessionRuntime | undefined): number {
if (!runtime) return 0
const seen = sessionState.sessions.find((x) => x.id === sessionId)?.lastSeenCount ?? 0
@@ -36,10 +36,12 @@
import type { SessionPreviewTabs } from '$lib/components/sessions/sessionPreviewTabs.svelte'
import { userWorkspaces, workspaceStore } from '$lib/stores'
import {
evictColdRuntimes,
getOrCreateRuntime,
getRuntime,
listRuntimes,
promoteEditorWarm
promoteEditorWarm,
promoteRuntimeWarm
} from '$lib/components/sessions/sessionRuntime.svelte'
import { markSessionSeen } from '$lib/components/sessions/sessionUnread.svelte'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
@@ -119,19 +121,32 @@
// deep links the same way they react to picker clicks.
selectSession(session.id)
getOrCreateRuntime(session)
// Mark this session most-recently-used so the eviction pass keeps it (and
// the last few) warm while disposing the rest.
promoteRuntimeWarm(session.id)
})
})
// Warm = sessions with a live runtime. The picker eagerly creates runtimes
// for its visible sessions, so this tracks whatever it shows. Keeping warm
// chats mounted (stacked, visibility-toggled) preserves their scroll/draft
// state across switches.
// Warm = sessions with a live runtime (active + MRU + any mid-stream session —
// see the eviction effect). Keeping warm chats mounted (stacked, visibility-
// toggled) preserves their scroll/draft state across switches, and capping the
// set bounds the page's mounted weight regardless of family size.
const warmSessions = $derived(
listRuntimes()
.map((r) => sessionState.sessions.find((s) => s.id === r.sessionId))
.filter((s): s is NonNullable<typeof s> => s != null)
)
// Dispose runtimes that fell out of the warm set. Re-runs when the active
// session changes, and — by reading each runtime's `loading` — when a
// background stream finishes, so a session kept warm only because it was
// streaming gets evicted once it settles (unless it's back in the MRU).
$effect(() => {
const activeId = activeSession?.id
for (const rt of listRuntimes()) void rt.manager.loading
untrack(() => evictColdRuntimes(activeId))
})
// Promote the active session in the LRU. Mutations untracked so the effect
// only re-runs when activeSession changes, not on its own writes.
$effect(() => {