Shoubhit Dash 1 месяц назад
Родитель
Сommit
af6538ac8d

+ 43 - 0
packages/opencode/src/cli/cmd/tui/context/sync-cache.ts

@@ -0,0 +1,43 @@
+import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2"
+import type { Snapshot } from "@/snapshot"
+
+export const SESSION_CACHE_LIMIT = 40
+
+type SessionCache = {
+  session_status: Record<string, SessionStatus | undefined>
+  session_diff: Record<string, Snapshot.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 dropSessionCache(store: SessionCache, sessionID: string) {
+  for (const key of Object.keys(store.part)) {
+    const parts = store.part[key]
+    if (!parts?.some((part) => part?.sessionID === sessionID)) continue
+    delete store.part[key]
+  }
+  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 }) {
+  const stale: string[] = []
+  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 (id === input.keep) continue
+    stale.push(id)
+  }
+  for (const id of stale) {
+    input.seen.delete(id)
+  }
+  return stale
+}

+ 120 - 45
packages/opencode/src/cli/cmd/tui/context/sync.tsx

@@ -25,9 +25,10 @@ import { createSimpleContext } from "./helper"
 import type { Snapshot } from "@/snapshot"
 import { useExit } from "./exit"
 import { useArgs } from "./args"
-import { batch, onMount } from "solid-js"
+import { batch, onCleanup, onMount } from "solid-js"
 import { Log } from "@/util/log"
 import type { Path } from "@opencode-ai/sdk"
+import { dropSessionCache, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./sync-cache"
 
 export const { use: useSync, provider: SyncProvider } = createSimpleContext({
   name: "Sync",
@@ -103,14 +104,45 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
     })
 
     const sdk = useSDK()
+    const cachedSessions = new Set<string>()
+    const fullSyncedSessions = new Set<string>()
+    const inflight = new Map<string, Promise<void>>()
+
+    const touchSession = (sessionID: string) => {
+      const stale = pickSessionCacheEvictions({
+        seen: cachedSessions,
+        keep: sessionID,
+        limit: SESSION_CACHE_LIMIT,
+      })
+      if (stale.length === 0) return
+      setStore(
+        produce((draft) => {
+          for (const id of stale) {
+            dropSessionCache(draft, id)
+            fullSyncedSessions.delete(id)
+          }
+        }),
+      )
+    }
+
+    const sessionForMessage = (messageID: string) => {
+      const parts = store.part[messageID]
+      const sessionID = parts?.find((part) => !!part?.sessionID)?.sessionID
+      if (sessionID) return sessionID
+      for (const [id, messages] of Object.entries(store.message)) {
+        if (messages?.some((message) => message.id === messageID)) return id
+      }
+      return undefined
+    }
 
-    sdk.event.listen((e) => {
+    const stop = sdk.event.listen((e) => {
       const event = e.details
       switch (event.type) {
         case "server.instance.disposed":
           bootstrap()
           break
         case "permission.replied": {
+          touchSession(event.properties.sessionID)
           const requests = store.permission[event.properties.sessionID]
           if (!requests) break
           const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
@@ -127,6 +159,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
 
         case "permission.asked": {
           const request = event.properties
+          touchSession(request.sessionID)
           const requests = store.permission[request.sessionID]
           if (!requests) {
             setStore("permission", request.sessionID, [request])
@@ -149,6 +182,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
 
         case "question.replied":
         case "question.rejected": {
+          touchSession(event.properties.sessionID)
           const requests = store.question[event.properties.sessionID]
           if (!requests) break
           const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
@@ -165,6 +199,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
 
         case "question.asked": {
           const request = event.properties
+          touchSession(request.sessionID)
           const requests = store.question[request.sessionID]
           if (!requests) {
             setStore("question", request.sessionID, [request])
@@ -186,46 +221,64 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         }
 
         case "todo.updated":
+          touchSession(event.properties.sessionID)
           setStore("todo", event.properties.sessionID, event.properties.todos)
           break
 
         case "session.diff":
+          touchSession(event.properties.sessionID)
           setStore("session_diff", event.properties.sessionID, event.properties.diff)
           break
 
         case "session.deleted": {
-          const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
-          if (result.found) {
+          const sessionID = event.properties.info.id
+          setStore(
+            produce((draft) => {
+              const result = Binary.search(draft.session, sessionID, (s) => s.id)
+              if (result.found) draft.session.splice(result.index, 1)
+              dropSessionCache(draft, sessionID)
+            }),
+          )
+          cachedSessions.delete(sessionID)
+          fullSyncedSessions.delete(sessionID)
+          break
+        }
+        case "session.updated": {
+          const info = event.properties.info
+          if (info.time.archived) {
             setStore(
-              "session",
               produce((draft) => {
-                draft.splice(result.index, 1)
+                const result = Binary.search(draft.session, info.id, (s) => s.id)
+                if (result.found) draft.session.splice(result.index, 1)
+                dropSessionCache(draft, info.id)
               }),
             )
+            cachedSessions.delete(info.id)
+            fullSyncedSessions.delete(info.id)
+            break
           }
-          break
-        }
-        case "session.updated": {
-          const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
+          const result = Binary.search(store.session, info.id, (s) => s.id)
           if (result.found) {
-            setStore("session", result.index, reconcile(event.properties.info))
+            setStore("session", result.index, reconcile(info))
             break
           }
           setStore(
             "session",
             produce((draft) => {
-              draft.splice(result.index, 0, event.properties.info)
+              draft.splice(result.index, 0, info)
             }),
           )
           break
         }
 
         case "session.status": {
+          touchSession(event.properties.sessionID)
           setStore("session_status", event.properties.sessionID, event.properties.status)
           break
         }
 
         case "message.updated": {
+          touchSession(event.properties.info.sessionID)
           const messages = store.message[event.properties.info.sessionID]
           if (!messages) {
             setStore("message", event.properties.info.sessionID, [event.properties.info])
@@ -265,20 +318,21 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           break
         }
         case "message.removed": {
-          const messages = store.message[event.properties.sessionID]
-          const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
-          if (result.found) {
-            setStore(
-              "message",
-              event.properties.sessionID,
-              produce((draft) => {
-                draft.splice(result.index, 1)
-              }),
-            )
-          }
+          touchSession(event.properties.sessionID)
+          setStore(
+            produce((draft) => {
+              const list = draft.message[event.properties.sessionID]
+              if (list) {
+                const next = Binary.search(list, event.properties.messageID, (m) => m.id)
+                if (next.found) list.splice(next.index, 1)
+              }
+              delete draft.part[event.properties.messageID]
+            }),
+          )
           break
         }
         case "message.part.updated": {
+          touchSession(event.properties.part.sessionID)
           const parts = store.part[event.properties.part.messageID]
           if (!parts) {
             setStore("part", event.properties.part.messageID, [event.properties.part])
@@ -300,6 +354,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         }
 
         case "message.part.delta": {
+          const sessionID = sessionForMessage(event.properties.messageID)
+          if (sessionID) touchSession(sessionID)
           const parts = store.part[event.properties.messageID]
           if (!parts) break
           const result = Binary.search(parts, event.properties.partID, (p) => p.id)
@@ -318,14 +374,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         }
 
         case "message.part.removed": {
+          const sessionID = sessionForMessage(event.properties.messageID)
+          if (sessionID) touchSession(sessionID)
           const parts = store.part[event.properties.messageID]
-          const result = Binary.search(parts, event.properties.partID, (p) => p.id)
-          if (result.found)
+          if (parts)
             setStore(
-              "part",
-              event.properties.messageID,
               produce((draft) => {
-                draft.splice(result.index, 1)
+                const list = draft.part[event.properties.messageID]
+                if (!list) return
+                const next = Binary.search(list, event.properties.partID, (p) => p.id)
+                if (!next.found) return
+                list.splice(next.index, 1)
+                if (list.length === 0) delete draft.part[event.properties.messageID]
               }),
             )
           break
@@ -342,6 +402,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         }
       }
     })
+    onCleanup(stop)
 
     const exit = useExit()
     const args = useArgs()
@@ -431,7 +492,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       bootstrap()
     })
 
-    const fullSyncedSessions = new Set<string>()
     const result = {
       data: store,
       set: setStore,
@@ -447,6 +507,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           if (match.found) return store.session[match.index]
           return undefined
         },
+        synced(sessionID: string) {
+          return fullSyncedSessions.has(sessionID) && store.message[sessionID] !== undefined
+        },
         status(sessionID: string) {
           const session = result.session.get(sessionID)
           if (!session) return "idle"
@@ -458,27 +521,39 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           return last.time.completed ? "idle" : "working"
         },
         async sync(sessionID: string) {
-          if (fullSyncedSessions.has(sessionID)) return
-          const [session, messages, todo, diff] = await Promise.all([
+          touchSession(sessionID)
+          if (fullSyncedSessions.has(sessionID) && store.message[sessionID] !== undefined) return
+          const existing = inflight.get(sessionID)
+          if (existing) return existing
+          const task = Promise.all([
             sdk.client.session.get({ sessionID }, { throwOnError: true }),
             sdk.client.session.messages({ sessionID, limit: 100 }),
             sdk.client.session.todo({ sessionID }),
             sdk.client.session.diff({ sessionID }),
           ])
-          setStore(
-            produce((draft) => {
-              const match = Binary.search(draft.session, sessionID, (s) => s.id)
-              if (match.found) draft.session[match.index] = session.data!
-              if (!match.found) draft.session.splice(match.index, 0, session.data!)
-              draft.todo[sessionID] = todo.data ?? []
-              draft.message[sessionID] = messages.data!.map((x) => x.info)
-              for (const message of messages.data!) {
-                draft.part[message.info.id] = message.parts
-              }
-              draft.session_diff[sessionID] = diff.data ?? []
-            }),
-          )
-          fullSyncedSessions.add(sessionID)
+            .then(([session, messages, todo, diff]) => {
+              if (!cachedSessions.has(sessionID)) return
+              cachedSessions.add(sessionID)
+              fullSyncedSessions.add(sessionID)
+              setStore(
+                produce((draft) => {
+                  const match = Binary.search(draft.session, sessionID, (s) => s.id)
+                  if (match.found) draft.session[match.index] = session.data!
+                  if (!match.found) draft.session.splice(match.index, 0, session.data!)
+                  draft.todo[sessionID] = todo.data ?? []
+                  draft.message[sessionID] = messages.data!.map((x) => x.info)
+                  for (const message of messages.data!) {
+                    draft.part[message.info.id] = message.parts
+                  }
+                  draft.session_diff[sessionID] = diff.data ?? []
+                }),
+              )
+            })
+            .finally(() => {
+              if (inflight.get(sessionID) === task) inflight.delete(sessionID)
+            })
+          inflight.set(sessionID, task)
+          return task
         },
       },
       bootstrap,

+ 14 - 8
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -7,6 +7,7 @@ import {
   For,
   Match,
   on,
+  onCleanup,
   onMount,
   Show,
   Switch,
@@ -182,16 +183,18 @@ export function Session() {
     return new CustomSpeedScroll(3)
   })
 
-  createEffect(async () => {
-    await sync.session
-      .sync(route.sessionID)
+  createEffect(() => {
+    const sessionID = route.sessionID
+    if (sync.session.synced(sessionID)) return
+    void sync.session
+      .sync(sessionID)
       .then(() => {
         if (scroll) scroll.scrollBy(100_000)
       })
       .catch((e) => {
         console.error(e)
         toast.show({
-          message: `Session not found: ${route.sessionID}`,
+          message: `Session not found: ${sessionID}`,
           variant: "error",
         })
         return navigate({ type: "home" })
@@ -209,7 +212,7 @@ export function Session() {
   })
 
   let lastSwitch: string | undefined = undefined
-  sdk.event.on("message.part.updated", (evt) => {
+  const unsub = sdk.event.on("message.part.updated", (evt) => {
     const part = evt.properties.part
     if (part.type !== "tool") return
     if (part.sessionID !== route.sessionID) return
@@ -224,6 +227,7 @@ export function Session() {
       lastSwitch = part.id
     }
   })
+  onCleanup(unsub)
 
   let scroll: ScrollBoxRenderable
   let prompt: PromptRef
@@ -1959,9 +1963,11 @@ function Task(props: ToolProps<typeof TaskTool>) {
   const local = useLocal()
   const sync = useSync()
 
-  onMount(() => {
-    if (props.metadata.sessionId && !sync.data.message[props.metadata.sessionId]?.length)
-      sync.session.sync(props.metadata.sessionId)
+  createEffect(() => {
+    const sessionID = props.metadata.sessionId
+    if (!sessionID) return
+    if (sync.session.synced(sessionID)) return
+    void sync.session.sync(sessionID)
   })
 
   const messages = createMemo(() => sync.data.message[props.metadata.sessionId ?? ""] ?? [])

+ 89 - 0
packages/opencode/test/cli/tui/sync-cache.test.ts

@@ -0,0 +1,89 @@
+import { describe, expect, test } from "bun:test"
+import type { Message, Part, SessionStatus, Todo, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
+import { dropSessionCache, pickSessionCacheEvictions } from "../../../src/cli/cmd/tui/context/sync-cache"
+
+const msg = (id: string, sessionID: string) =>
+  ({
+    id,
+    sessionID,
+    role: "user",
+    time: { created: 1 },
+    agent: "build",
+    model: { providerID: "openai", modelID: "gpt-4o-mini" },
+  }) as Message
+
+const part = (id: string, sessionID: string, messageID: string) =>
+  ({
+    id,
+    sessionID,
+    messageID,
+    type: "text",
+    text: id,
+  }) as Part
+
+describe("tui sync cache", () => {
+  test("dropSessionCache clears session scoped maps and related parts", () => {
+    const m = msg("msg_1", "ses_1")
+    const store = {
+      session_status: { ses_1: { type: "busy" } as SessionStatus },
+      session_diff: { ses_1: [] },
+      todo: { ses_1: [] as Todo[] },
+      message: { ses_1: [m] },
+      part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
+      permission: { ses_1: [] as PermissionRequest[] },
+      question: { ses_1: [] as QuestionRequest[] },
+    }
+
+    dropSessionCache(store, "ses_1")
+
+    expect(store.message.ses_1).toBeUndefined()
+    expect(store.part[m.id]).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("dropSessionCache clears orphaned parts without message rows", () => {
+    const store = {
+      session_status: {},
+      session_diff: {},
+      todo: {},
+      message: {},
+      part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
+      permission: {},
+      question: {},
+    }
+
+    dropSessionCache(store, "ses_1")
+
+    expect(store.part.msg_1).toBeUndefined()
+  })
+
+  test("pickSessionCacheEvictions evicts oldest cached sessions", () => {
+    const seen = new Set(["ses_1", "ses_2", "ses_3"])
+
+    const stale = pickSessionCacheEvictions({
+      seen,
+      keep: "ses_4",
+      limit: 2,
+    })
+
+    expect(stale).toEqual(["ses_1", "ses_2"])
+    expect([...seen]).toEqual(["ses_3", "ses_4"])
+  })
+
+  test("pickSessionCacheEvictions refreshes recency for revisited sessions", () => {
+    const seen = new Set(["ses_1", "ses_2", "ses_3"])
+
+    const stale = pickSessionCacheEvictions({
+      seen,
+      keep: "ses_2",
+      limit: 3,
+    })
+
+    expect(stale).toEqual([])
+    expect([...seen]).toEqual(["ses_1", "ses_3", "ses_2"])
+  })
+})