Переглянути джерело

fix: prune and evict stale app session caches (#16584)

Shoubhit Dash 5 місяців тому
батько
коміт
a139e9297d

+ 3 - 1
packages/app/src/context/global-sync.tsx

@@ -27,7 +27,7 @@ import type { InitError } from "../pages/error"
 import { useGlobalSDK } from "./global-sdk"
 import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
 import { createChildStoreManager } from "./global-sync/child-store"
-import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
+import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
 import { createRefreshQueue } from "./global-sync/queue"
 import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
 import { trimSessions } from "./global-sync/session-trim"
@@ -189,6 +189,7 @@ function createGlobalSync() {
       })
       if (next.length !== store.session.length) {
         setStore("session", reconcile(next, { key: "id" }))
+        cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
       }
       children.unpin(directory)
       return
@@ -220,6 +221,7 @@ function createGlobalSync() {
           }),
         )
         setStore("session", reconcile(sessions, { key: "id" }))
+        cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
         sessionMeta.set(directory, { limit })
       })
       .catch((err) => {

+ 57 - 1
packages/app/src/context/global-sync/event-reducer.test.ts

@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
 import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
 import { createStore } from "solid-js/store"
 import type { State } from "./types"
-import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
+import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
 
 const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
   ({
@@ -248,6 +248,62 @@ describe("applyDirectoryEvent", () => {
     }
   })
 
+  test("cleans caches for trimmed sessions on session.created", () => {
+    const dropped = rootSession({ id: "ses_b" })
+    const kept = rootSession({ id: "ses_a" })
+    const message = userMessage("msg_1", dropped.id)
+    const todos: string[] = []
+    const [store, setStore] = createStore(
+      baseState({
+        limit: 1,
+        session: [dropped],
+        message: { [dropped.id]: [message] },
+        part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] },
+        session_diff: { [dropped.id]: [] },
+        todo: { [dropped.id]: [] },
+        permission: { [dropped.id]: [] },
+        question: { [dropped.id]: [] },
+        session_status: { [dropped.id]: { type: "busy" } },
+      }),
+    )
+
+    applyDirectoryEvent({
+      event: { type: "session.created", properties: { info: kept } },
+      store,
+      setStore,
+      push() {},
+      directory: "/tmp",
+      loadLsp() {},
+      setSessionTodo(sessionID, value) {
+        if (value !== undefined) return
+        todos.push(sessionID)
+      },
+    })
+
+    expect(store.session.map((x) => x.id)).toEqual([kept.id])
+    expect(store.message[dropped.id]).toBeUndefined()
+    expect(store.part[message.id]).toBeUndefined()
+    expect(store.session_diff[dropped.id]).toBeUndefined()
+    expect(store.todo[dropped.id]).toBeUndefined()
+    expect(store.permission[dropped.id]).toBeUndefined()
+    expect(store.question[dropped.id]).toBeUndefined()
+    expect(store.session_status[dropped.id]).toBeUndefined()
+    expect(todos).toEqual([dropped.id])
+  })
+
+  test("cleanupDroppedSessionCaches clears part-only orphan state", () => {
+    const [store, setStore] = createStore(
+      baseState({
+        session: [rootSession({ id: "ses_keep" })],
+        part: { msg_1: [textPart("prt_1", "ses_drop", "msg_1")] },
+      }),
+    )
+
+    cleanupDroppedSessionCaches(store, setStore, store.session)
+
+    expect(store.part.msg_1).toBeUndefined()
+  })
+
   test("upserts and removes messages while clearing orphaned parts", () => {
     const sessionID = "ses_1"
     const [store, setStore] = createStore(

+ 35 - 25
packages/app/src/context/global-sync/event-reducer.ts

@@ -13,6 +13,7 @@ import type {
 } from "@opencode-ai/sdk/v2/client"
 import type { State, VcsCache } from "./types"
 import { trimSessions } from "./session-trim"
+import { dropSessionCaches } from "./session-cache"
 
 export function applyGlobalEvent(input: {
   event: { type: string; properties?: unknown }
@@ -40,37 +41,44 @@ export function applyGlobalEvent(input: {
 }
 
 function cleanupSessionCaches(
-  store: Store<State>,
   setStore: SetStoreFunction<State>,
   sessionID: string,
   setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
 ) {
   if (!sessionID) return
-  const hasAny =
-    store.message[sessionID] !== undefined ||
-    store.session_diff[sessionID] !== undefined ||
-    store.todo[sessionID] !== undefined ||
-    store.permission[sessionID] !== undefined ||
-    store.question[sessionID] !== undefined ||
-    store.session_status[sessionID] !== undefined
   setSessionTodo?.(sessionID, undefined)
-  if (!hasAny) return
   setStore(
     produce((draft) => {
-      const messages = draft.message[sessionID]
-      if (messages) {
-        for (const message of messages) {
-          const id = message?.id
-          if (!id) continue
-          delete draft.part[id]
-        }
-      }
-      delete draft.message[sessionID]
-      delete draft.session_diff[sessionID]
-      delete draft.todo[sessionID]
-      delete draft.permission[sessionID]
-      delete draft.question[sessionID]
-      delete draft.session_status[sessionID]
+      dropSessionCaches(draft, [sessionID])
+    }),
+  )
+}
+
+export function cleanupDroppedSessionCaches(
+  store: Store<State>,
+  setStore: SetStoreFunction<State>,
+  next: Session[],
+  setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
+) {
+  const keep = new Set(next.map((item) => item.id))
+  const stale = [
+    ...Object.keys(store.message),
+    ...Object.keys(store.session_diff),
+    ...Object.keys(store.todo),
+    ...Object.keys(store.permission),
+    ...Object.keys(store.question),
+    ...Object.keys(store.session_status),
+    ...Object.values(store.part)
+      .map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID)
+      .filter((sessionID): sessionID is string => !!sessionID),
+  ].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
+  if (stale.length === 0) return
+  for (const sessionID of stale) {
+    setSessionTodo?.(sessionID, undefined)
+  }
+  setStore(
+    produce((draft) => {
+      dropSessionCaches(draft, stale)
     }),
   )
 }
@@ -102,6 +110,7 @@ export function applyDirectoryEvent(input: {
       next.splice(result.index, 0, info)
       const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
       input.setStore("session", reconcile(trimmed, { key: "id" }))
+      cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
       if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
       break
     }
@@ -117,7 +126,7 @@ export function applyDirectoryEvent(input: {
             }),
           )
         }
-        cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
+        cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
         if (info.parentID) break
         input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
         break
@@ -130,6 +139,7 @@ export function applyDirectoryEvent(input: {
       next.splice(result.index, 0, info)
       const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
       input.setStore("session", reconcile(trimmed, { key: "id" }))
+      cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
       break
     }
     case "session.deleted": {
@@ -143,7 +153,7 @@ export function applyDirectoryEvent(input: {
           }),
         )
       }
-      cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
+      cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
       if (info.parentID) break
       input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
       break

+ 102 - 0
packages/app/src/context/global-sync/session-cache.test.ts

@@ -0,0 +1,102 @@
+import { describe, expect, test } from "bun:test"
+import type {
+  FileDiff,
+  Message,
+  Part,
+  PermissionRequest,
+  QuestionRequest,
+  SessionStatus,
+  Todo,
+} from "@opencode-ai/sdk/v2/client"
+import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
+
+const msg = (id: string, sessionID: string) =>
+  ({
+    id,
+    sessionID,
+    role: "user",
+    time: { created: 1 },
+    agent: "assistant",
+    model: { providerID: "openai", modelID: "gpt" },
+  }) as Message
+
+const part = (id: string, sessionID: string, messageID: string) =>
+  ({
+    id,
+    sessionID,
+    messageID,
+    type: "text",
+    text: id,
+  }) as Part
+
+describe("app session cache", () => {
+  test("dropSessionCaches clears orphaned parts without message rows", () => {
+    const store: {
+      session_status: Record<string, SessionStatus | undefined>
+      session_diff: Record<string, FileDiff[] | undefined>
+      todo: Record<string, Todo[] | undefined>
+      message: Record<string, Message[] | undefined>
+      part: Record<string, Part[] | undefined>
+      permission: Record<string, PermissionRequest[] | undefined>
+      question: Record<string, QuestionRequest[] | undefined>
+    } = {
+      session_status: { ses_1: { type: "busy" } as SessionStatus },
+      session_diff: { ses_1: [] },
+      todo: { ses_1: [] as Todo[] },
+      message: {},
+      part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
+      permission: { ses_1: [] as PermissionRequest[] },
+      question: { ses_1: [] as QuestionRequest[] },
+    }
+
+    dropSessionCaches(store, ["ses_1"])
+
+    expect(store.message.ses_1).toBeUndefined()
+    expect(store.part.msg_1).toBeUndefined()
+    expect(store.todo.ses_1).toBeUndefined()
+    expect(store.session_diff.ses_1).toBeUndefined()
+    expect(store.session_status.ses_1).toBeUndefined()
+    expect(store.permission.ses_1).toBeUndefined()
+    expect(store.question.ses_1).toBeUndefined()
+  })
+
+  test("dropSessionCaches clears message-backed parts", () => {
+    const m = msg("msg_1", "ses_1")
+    const store: {
+      session_status: Record<string, SessionStatus | undefined>
+      session_diff: Record<string, FileDiff[] | undefined>
+      todo: Record<string, Todo[] | undefined>
+      message: Record<string, Message[] | undefined>
+      part: Record<string, Part[] | undefined>
+      permission: Record<string, PermissionRequest[] | undefined>
+      question: Record<string, QuestionRequest[] | undefined>
+    } = {
+      session_status: {},
+      session_diff: {},
+      todo: {},
+      message: { ses_1: [m] },
+      part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
+      permission: {},
+      question: {},
+    }
+
+    dropSessionCaches(store, ["ses_1"])
+
+    expect(store.message.ses_1).toBeUndefined()
+    expect(store.part[m.id]).toBeUndefined()
+  })
+
+  test("pickSessionCacheEvictions preserves requested sessions", () => {
+    const seen = new Set(["ses_1", "ses_2", "ses_3"])
+
+    const stale = pickSessionCacheEvictions({
+      seen,
+      keep: "ses_4",
+      limit: 2,
+      preserve: ["ses_1"],
+    })
+
+    expect(stale).toEqual(["ses_2", "ses_3"])
+    expect([...seen]).toEqual(["ses_1", "ses_4"])
+  })
+})

+ 62 - 0
packages/app/src/context/global-sync/session-cache.ts

@@ -0,0 +1,62 @@
+import type {
+  FileDiff,
+  Message,
+  Part,
+  PermissionRequest,
+  QuestionRequest,
+  SessionStatus,
+  Todo,
+} from "@opencode-ai/sdk/v2/client"
+
+export const SESSION_CACHE_LIMIT = 40
+
+type SessionCache = {
+  session_status: Record<string, SessionStatus | undefined>
+  session_diff: Record<string, FileDiff[] | undefined>
+  todo: Record<string, Todo[] | undefined>
+  message: Record<string, Message[] | undefined>
+  part: Record<string, Part[] | undefined>
+  permission: Record<string, PermissionRequest[] | undefined>
+  question: Record<string, QuestionRequest[] | undefined>
+}
+
+export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) {
+  const stale = new Set(Array.from(sessionIDs).filter(Boolean))
+  if (stale.size === 0) return
+
+  for (const key of Object.keys(store.part)) {
+    const parts = store.part[key]
+    if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue
+    delete store.part[key]
+  }
+
+  for (const sessionID of stale) {
+    delete store.message[sessionID]
+    delete store.todo[sessionID]
+    delete store.session_diff[sessionID]
+    delete store.session_status[sessionID]
+    delete store.permission[sessionID]
+    delete store.question[sessionID]
+  }
+}
+
+export function pickSessionCacheEvictions(input: {
+  seen: Set<string>
+  keep: string
+  limit: number
+  preserve?: Iterable<string>
+}) {
+  const stale: string[] = []
+  const keep = new Set([input.keep, ...Array.from(input.preserve ?? [])])
+  if (input.seen.has(input.keep)) input.seen.delete(input.keep)
+  input.seen.add(input.keep)
+  for (const id of input.seen) {
+    if (input.seen.size - stale.length <= input.limit) break
+    if (keep.has(id)) continue
+    stale.push(id)
+  }
+  for (const id of stale) {
+    input.seen.delete(id)
+  }
+  return stale
+}

+ 78 - 0
packages/app/src/context/sync.tsx

@@ -6,6 +6,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useGlobalSync } from "./global-sync"
 import { useSDK } from "./sdk"
 import type { Message, Part } from "@opencode-ai/sdk/v2/client"
+import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
 
 function sortParts(parts: Part[]) {
   return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
@@ -108,6 +109,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
     const inflight = new Map<string, Promise<void>>()
     const inflightDiff = new Map<string, Promise<void>>()
     const inflightTodo = new Map<string, Promise<void>>()
+    const maxDirs = 30
+    const seen = new Map<string, Set<string>>()
     const [meta, setMeta] = createStore({
       limit: {} as Record<string, number>,
       complete: {} as Record<string, boolean>,
@@ -121,6 +124,62 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       return undefined
     }
 
+    const seenFor = (directory: string) => {
+      const existing = seen.get(directory)
+      if (existing) {
+        seen.delete(directory)
+        seen.set(directory, existing)
+        return existing
+      }
+      const created = new Set<string>()
+      seen.set(directory, created)
+      while (seen.size > maxDirs) {
+        const first = seen.keys().next().value
+        if (!first) break
+        const stale = [...(seen.get(first) ?? [])]
+        seen.delete(first)
+        const [, setStore] = globalSync.child(first, { bootstrap: false })
+        evict(first, setStore, stale)
+      }
+      return created
+    }
+
+    const clearMeta = (directory: string, sessionIDs: string[]) => {
+      if (sessionIDs.length === 0) return
+      setMeta(
+        produce((draft) => {
+          for (const sessionID of sessionIDs) {
+            const key = keyFor(directory, sessionID)
+            delete draft.limit[key]
+            delete draft.complete[key]
+            delete draft.loading[key]
+          }
+        }),
+      )
+    }
+
+    const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
+      if (sessionIDs.length === 0) return
+      for (const sessionID of sessionIDs) {
+        globalSync.todo.set(sessionID, undefined)
+      }
+      setStore(
+        produce((draft) => {
+          dropSessionCaches(draft, sessionIDs)
+        }),
+      )
+      clearMeta(directory, sessionIDs)
+    }
+
+    const touch = (directory: string, setStore: Setter, sessionID: string) => {
+      const stale = pickSessionCacheEvictions({
+        seen: seenFor(directory),
+        keep: sessionID,
+        limit: SESSION_CACHE_LIMIT,
+      })
+      evict(directory, setStore, stale)
+    }
+
     const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
       const messages = await retry(() =>
         input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
@@ -135,6 +194,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       }
     }
 
+    const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
+
     const loadMessages = async (input: {
       directory: string
       client: typeof sdk.client
@@ -148,6 +209,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       setMeta("loading", key, true)
       await fetchMessages(input)
         .then((next) => {
+          if (!tracked(input.directory, input.sessionID)) return
           batch(() => {
             input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
             for (const p of next.part) {
@@ -158,6 +220,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           })
         })
         .finally(() => {
+          if (!tracked(input.directory, input.sessionID)) return
           setMeta("loading", key, false)
         })
     }
@@ -224,11 +287,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           const key = keyFor(directory, sessionID)
           const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
 
+          touch(directory, setStore, sessionID)
+
+          if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return
+
           const limit = meta.limit[key] ?? messagePageSize
 
           const sessionReq = hasSession
             ? Promise.resolve()
             : retry(() => client.session.get({ sessionID })).then((session) => {
+                if (!tracked(directory, sessionID)) return
                 const data = session.data
                 if (!data) return
                 setStore(
@@ -258,11 +326,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           const directory = sdk.directory
           const client = sdk.client
           const [store, setStore] = globalSync.child(directory)
+          touch(directory, setStore, sessionID)
           if (store.session_diff[sessionID] !== undefined) return
 
           const key = keyFor(directory, sessionID)
           return runInflight(inflightDiff, key, () =>
             retry(() => client.session.diff({ sessionID })).then((diff) => {
+              if (!tracked(directory, sessionID)) return
               setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
             }),
           )
@@ -271,6 +341,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           const directory = sdk.directory
           const client = sdk.client
           const [store, setStore] = globalSync.child(directory)
+          touch(directory, setStore, sessionID)
           const existing = store.todo[sessionID]
           const cached = globalSync.data.session_todo[sessionID]
           if (existing !== undefined) {
@@ -287,6 +358,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           const key = keyFor(directory, sessionID)
           return runInflight(inflightTodo, key, () =>
             retry(() => client.session.todo({ sessionID })).then((todo) => {
+              if (!tracked(directory, sessionID)) return
               const list = todo.data ?? []
               setStore("todo", sessionID, reconcile(list, { key: "id" }))
               globalSync.todo.set(sessionID, list)
@@ -310,6 +382,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             const directory = sdk.directory
             const client = sdk.client
             const [, setStore] = globalSync.child(directory)
+            touch(directory, setStore, sessionID)
             const key = keyFor(directory, sessionID)
             const step = count ?? messagePageSize
             if (meta.loading[key]) return
@@ -325,6 +398,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             })
           },
         },
+        evict(sessionID: string, directory = sdk.directory) {
+          const [, setStore] = globalSync.child(directory)
+          seenFor(directory).delete(sessionID)
+          evict(directory, setStore, [sessionID])
+        },
         fetch: async (count = 10) => {
           const directory = sdk.directory
           const client = sdk.client

+ 22 - 10
packages/app/src/pages/layout.tsx

@@ -34,6 +34,7 @@ import { useProviders } from "@/hooks/use-providers"
 import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { clearWorkspaceTerminals } from "@/context/terminal"
+import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
 import { useNotification } from "@/context/notification"
 import { usePermission } from "@/context/permission"
 import { Binary } from "@opencode-ai/util/binary"
@@ -657,25 +658,24 @@ export default function Layout(props: ParentProps) {
   const prefetchQueues = new Map<string, PrefetchQueue>()
 
   const PREFETCH_MAX_SESSIONS_PER_DIR = 10
-  const prefetchedByDir = new Map<string, Map<string, true>>()
+  const prefetchedByDir = new Map<string, Set<string>>()
 
   const lruFor = (directory: string) => {
     const existing = prefetchedByDir.get(directory)
     if (existing) return existing
-    const created = new Map<string, true>()
+    const created = new Set<string>()
     prefetchedByDir.set(directory, created)
     return created
   }
 
   const markPrefetched = (directory: string, sessionID: string) => {
     const lru = lruFor(directory)
-    if (lru.has(sessionID)) lru.delete(sessionID)
-    lru.set(sessionID, true)
-    while (lru.size > PREFETCH_MAX_SESSIONS_PER_DIR) {
-      const oldest = lru.keys().next().value as string | undefined
-      if (!oldest) return
-      lru.delete(oldest)
-    }
+    return pickSessionCacheEvictions({
+      seen: lru,
+      keep: sessionID,
+      limit: PREFETCH_MAX_SESSIONS_PER_DIR,
+      preserve: directory === params.dir && params.id ? [params.id] : undefined,
+    })
   }
 
   createEffect(() => {
@@ -724,6 +724,7 @@ export default function Layout(props: ParentProps) {
     return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
       .then((messages) => {
         if (prefetchToken.value !== token) return
+        if (!lruFor(directory).has(sessionID)) return
 
         const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
         const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
@@ -787,7 +788,18 @@ export default function Layout(props: ParentProps) {
     const lru = lruFor(directory)
     const known = lru.has(session.id)
     if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return
-    markPrefetched(directory, session.id)
+    const stale = markPrefetched(directory, session.id)
+    if (stale.length > 0) {
+      const [, setStore] = globalSync.child(directory, { bootstrap: false })
+      for (const id of stale) {
+        globalSync.todo.set(id, undefined)
+      }
+      setStore(
+        produce((draft) => {
+          dropSessionCaches(draft, stale)
+        }),
+      )
+    }
 
     if (priority === "high") q.pending.unshift(session.id)
     if (priority !== "high") q.pending.push(session.id)

+ 6 - 4
packages/app/src/pages/session.tsx

@@ -426,10 +426,12 @@ export default function Page() {
 
   createEffect(
     on(
-      () => params.id,
-      (id, prev) => {
-        if (id || !prev) return
-        resetSessionModel(local)
+      () => ({ dir: params.dir, id: params.id }),
+      (next, prev) => {
+        if (!prev) return
+        if (next.dir === prev.dir && next.id === prev.id) return
+        if (prev.id) sync.session.evict(prev.id, prev.dir)
+        if (!next.id) resetSessionModel(local)
       },
       { defer: true },
     ),