ソースを参照

fix(app): handle deleted session sync cache in V2 titlebar (#29328)

Eric Guo 2 ヶ月 前
コミット
1a8fd0e1dc

+ 28 - 0
packages/app/src/components/titlebar-session-events.test.ts

@@ -0,0 +1,28 @@
+import { describe, expect, test } from "bun:test"
+import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events"
+
+describe("titlebar session events", () => {
+  test("reads valid removed session tab details", () => {
+    expect(
+      readSessionTabsRemovedDetail(
+        new CustomEvent(SESSION_TABS_REMOVED_EVENT, {
+          detail: { directory: "/tmp/project", sessionIDs: ["ses_1", "ses_2", 1] },
+        }),
+      ),
+    ).toEqual({
+      directory: "/tmp/project",
+      sessionIDs: ["ses_1", "ses_2"],
+    })
+  })
+
+  test("ignores invalid removed session tab details", () => {
+    expect(readSessionTabsRemovedDetail(new Event(SESSION_TABS_REMOVED_EVENT))).toBeUndefined()
+    expect(
+      readSessionTabsRemovedDetail(
+        new CustomEvent(SESSION_TABS_REMOVED_EVENT, {
+          detail: { directory: "/tmp/project", sessionIDs: [] },
+        }),
+      ),
+    ).toBeUndefined()
+  })
+})

+ 29 - 0
packages/app/src/components/titlebar-session-events.ts

@@ -0,0 +1,29 @@
+export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed"
+
+export type SessionTabsRemovedDetail = {
+  directory: string
+  sessionIDs: string[]
+}
+
+export function notifySessionTabsRemoved(input: SessionTabsRemovedDetail) {
+  window.dispatchEvent(new CustomEvent(SESSION_TABS_REMOVED_EVENT, { detail: input }))
+}
+
+export function readSessionTabsRemovedDetail(event: Event): SessionTabsRemovedDetail | undefined {
+  if (!(event instanceof CustomEvent)) return undefined
+
+  const detail: unknown = event.detail
+  if (!detail || typeof detail !== "object") return undefined
+  if (!("directory" in detail)) return undefined
+  if (!("sessionIDs" in detail)) return undefined
+  if (typeof detail.directory !== "string") return undefined
+  if (!Array.isArray(detail.sessionIDs)) return undefined
+
+  const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string")
+  if (sessionIDs.length === 0) return undefined
+
+  return {
+    directory: detail.directory,
+    sessionIDs,
+  }
+}

+ 40 - 1
packages/app/src/components/titlebar.tsx

@@ -24,6 +24,11 @@ import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
 import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import { StatusPopoverV2 } from "@/components/status-popover"
+import {
+  readSessionTabsRemovedDetail,
+  SESSION_TABS_REMOVED_EVENT,
+  type SessionTabsRemovedDetail,
+} from "@/components/titlebar-session-events"
 
 type TauriDesktopWindow = {
   startDragging?: () => Promise<void>
@@ -276,7 +281,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                   )
                 },
                 removeTab: (href: string) => {
-                  startTransition(() => {
+                  void startTransition(() => {
                     setStore(
                       produce((tabs) => {
                         const index = tabs.findIndex((t) => t.href === href)
@@ -289,11 +294,45 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                     )
                   })
                 },
+                removeSessions: (input: SessionTabsRemovedDetail) => {
+                  void startTransition(() => {
+                    setStore(
+                      produce((tabs) => {
+                        const sessionIDs = new Set(input.sessionIDs)
+                        const currentHref = params.dir && params.id ? makeSessionHref(params.dir, params.id) : undefined
+                        const currentIndex = currentHref ? tabs.findIndex((tab) => tab.href === currentHref) : -1
+                        const removedCurrent =
+                          currentIndex !== -1 &&
+                          tabs[currentIndex]?.dir === input.directory &&
+                          sessionIDs.has(tabs[currentIndex]?.sessionId ?? "")
+
+                        for (let i = tabs.length - 1; i >= 0; i--) {
+                          const tab = tabs[i]
+                          if (!tab) continue
+                          if (tab.dir !== input.directory) continue
+                          if (!sessionIDs.has(tab.sessionId)) continue
+                          tabs.splice(i, 1)
+                        }
+
+                        if (!removedCurrent) return
+                        const nextTab = tabs[currentIndex] ?? tabs[tabs.length - 1]
+                        if (nextTab) navigate(nextTab.href)
+                        else navigate("/")
+                      }),
+                    )
+                  })
+                },
               }
 
               return [store, actions]
             })
 
+            makeEventListener(window, SESSION_TABS_REMOVED_EVENT, (event) => {
+              const detail = readSessionTabsRemovedDetail(event)
+              if (!detail) return
+              tabsStoreActions.removeSessions(detail)
+            })
+
             createEffect(() => {
               const params = useParams()
               if (!(params.dir && params.id)) return

+ 33 - 18
packages/app/src/context/directory-sync.ts

@@ -8,8 +8,8 @@ import {
   getSessionPrefetchPromise,
   setSessionPrefetch,
 } from "./global-sync/session-prefetch"
-import { createServerSyncContext, useServerSync } from "./server-sync"
-import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
+import { createServerSyncContext } from "./server-sync"
+import type { Message, Part } from "@opencode-ai/sdk/v2/client"
 import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
 import { diffs as list, message as clean } from "@/utils/diffs"
 import { useServerSDK } from "./server-sdk"
@@ -34,6 +34,12 @@ const keyFor = (directory: string, id: string) => `${directory}\n${id}`
 
 const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
 
+const isNotFound = (error: unknown) =>
+  error instanceof Error &&
+  typeof error.cause === "object" &&
+  error.cause !== null &&
+  (error.cause as { status?: unknown }).status === 404
+
 function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
   const map = new Map(a.map((item) => [item.id, item] as const))
   for (const item of b) map.set(item.id, item)
@@ -347,6 +353,10 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
           })
         })
       })
+      .catch((error) => {
+        if (isNotFound(error) && !tracked(input.directory, input.sessionID)) return
+        throw error
+      })
       .finally(() => {
         setMeta(
           produce((draft) => {
@@ -458,22 +468,27 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
           const sessionReq =
             hasSession && !opts?.force
               ? Promise.resolve()
-              : retry(() => client.session.get({ sessionID })).then((session) => {
-                  if (!tracked(directory, sessionID)) return
-                  const data = session.data
-                  if (!data) return
-                  setStore(
-                    "session",
-                    produce((draft) => {
-                      const match = Binary.search(draft, sessionID, (s) => s.id)
-                      if (match.found) {
-                        draft[match.index] = data
-                        return
-                      }
-                      draft.splice(match.index, 0, data)
-                    }),
-                  )
-                })
+              : retry(() => client.session.get({ sessionID }))
+                  .then((session) => {
+                    if (!tracked(directory, sessionID)) return
+                    const data = session.data
+                    if (!data) return
+                    setStore(
+                      "session",
+                      produce((draft) => {
+                        const match = Binary.search(draft, sessionID, (s) => s.id)
+                        if (match.found) {
+                          draft[match.index] = data
+                          return
+                        }
+                        draft.splice(match.index, 0, data)
+                      }),
+                    )
+                  })
+                  .catch((error) => {
+                    if (isNotFound(error) && !tracked(directory, sessionID)) return
+                    throw error
+                  })
 
           const messagesReq =
             cached && !opts?.force

+ 35 - 28
packages/app/src/pages/session/message-timeline.tsx

@@ -65,6 +65,7 @@ import { usePlatform } from "@/context/platform"
 import { useSettings } from "@/context/settings"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
+import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
 import { messageAgentColor } from "@/utils/agent"
 import { sessionTitle } from "@/utils/session-title"
 import { makeTimer } from "@solid-primitives/timer"
@@ -861,7 +862,9 @@ export function MessageTimeline(props: {
             if (index !== -1) draft.session.splice(index, 1)
           }),
         )
+        sync.session.evict(sessionID)
         navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
+        notifySessionTabsRemoved({ directory: sdk.directory, sessionIDs: [sessionID] })
       })
       .catch((err) => {
         showToast({
@@ -892,42 +895,46 @@ export function MessageTimeline(props: {
 
     if (!result) return false
 
-    sync.set(
-      produce((draft) => {
-        const removed = new Set<string>([sessionID])
-
-        const byParent = new Map<string, string[]>()
-        for (const item of draft.session) {
-          const parentID = item.parentID
-          if (!parentID) continue
-          const existing = byParent.get(parentID)
-          if (existing) {
-            existing.push(item.id)
-            continue
-          }
-          byParent.set(parentID, [item.id])
-        }
+    const removed = new Set<string>([sessionID])
+    const byParent = new Map<string, string[]>()
+    for (const item of sync.data.session) {
+      const parentID = item.parentID
+      if (!parentID) continue
+      const existing = byParent.get(parentID)
+      if (existing) {
+        existing.push(item.id)
+        continue
+      }
+      byParent.set(parentID, [item.id])
+    }
 
-        const stack = [sessionID]
-        while (stack.length) {
-          const parentID = stack.pop()
-          if (!parentID) continue
+    const stack = [sessionID]
+    while (stack.length) {
+      const parentID = stack.pop()
+      if (!parentID) continue
 
-          const children = byParent.get(parentID)
-          if (!children) continue
+      const children = byParent.get(parentID)
+      if (!children) continue
 
-          for (const child of children) {
-            if (removed.has(child)) continue
-            removed.add(child)
-            stack.push(child)
-          }
-        }
+      for (const child of children) {
+        if (removed.has(child)) continue
+        removed.add(child)
+        stack.push(child)
+      }
+    }
+
+    navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
 
+    sync.set(
+      produce((draft) => {
         draft.session = draft.session.filter((s) => !removed.has(s.id))
       }),
     )
 
-    navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
+    for (const id of removed) {
+      sync.session.evict(id)
+    }
+    notifySessionTabsRemoved({ directory: sdk.directory, sessionIDs: [...removed] })
     return true
   }