fix(app): handle deleted session sync cache in V2 titlebar (#29328)
This commit is contained in:
parent
46140b0cc8
commit
1a8fd0e1dc
5 changed files with 165 additions and 47 deletions
28
packages/app/src/components/titlebar-session-events.test.ts
Normal file
28
packages/app/src/components/titlebar-session-events.test.ts
Normal file
|
|
@ -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
packages/app/src/components/titlebar-session-events.ts
Normal file
29
packages/app/src/components/titlebar-session-events.ts
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { StatusPopoverV2 } from "@/components/status-popover"
|
import { StatusPopoverV2 } from "@/components/status-popover"
|
||||||
|
import {
|
||||||
|
readSessionTabsRemovedDetail,
|
||||||
|
SESSION_TABS_REMOVED_EVENT,
|
||||||
|
type SessionTabsRemovedDetail,
|
||||||
|
} from "@/components/titlebar-session-events"
|
||||||
|
|
||||||
type TauriDesktopWindow = {
|
type TauriDesktopWindow = {
|
||||||
startDragging?: () => Promise<void>
|
startDragging?: () => Promise<void>
|
||||||
|
|
@ -276,7 +281,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
removeTab: (href: string) => {
|
removeTab: (href: string) => {
|
||||||
startTransition(() => {
|
void startTransition(() => {
|
||||||
setStore(
|
setStore(
|
||||||
produce((tabs) => {
|
produce((tabs) => {
|
||||||
const index = tabs.findIndex((t) => t.href === href)
|
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]
|
return [store, actions]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
makeEventListener(window, SESSION_TABS_REMOVED_EVENT, (event) => {
|
||||||
|
const detail = readSessionTabsRemovedDetail(event)
|
||||||
|
if (!detail) return
|
||||||
|
tabsStoreActions.removeSessions(detail)
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
if (!(params.dir && params.id)) return
|
if (!(params.dir && params.id)) return
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import {
|
||||||
getSessionPrefetchPromise,
|
getSessionPrefetchPromise,
|
||||||
setSessionPrefetch,
|
setSessionPrefetch,
|
||||||
} from "./global-sync/session-prefetch"
|
} from "./global-sync/session-prefetch"
|
||||||
import { createServerSyncContext, useServerSync } from "./server-sync"
|
import { createServerSyncContext } from "./server-sync"
|
||||||
import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||||
import { diffs as list, message as clean } from "@/utils/diffs"
|
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||||
import { useServerSDK } from "./server-sdk"
|
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 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[]) {
|
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
|
||||||
const map = new Map(a.map((item) => [item.id, item] as const))
|
const map = new Map(a.map((item) => [item.id, item] as const))
|
||||||
for (const item of b) map.set(item.id, item)
|
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(() => {
|
.finally(() => {
|
||||||
setMeta(
|
setMeta(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|
@ -458,22 +468,27 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
|
||||||
const sessionReq =
|
const sessionReq =
|
||||||
hasSession && !opts?.force
|
hasSession && !opts?.force
|
||||||
? Promise.resolve()
|
? Promise.resolve()
|
||||||
: retry(() => client.session.get({ sessionID })).then((session) => {
|
: retry(() => client.session.get({ sessionID }))
|
||||||
if (!tracked(directory, sessionID)) return
|
.then((session) => {
|
||||||
const data = session.data
|
if (!tracked(directory, sessionID)) return
|
||||||
if (!data) return
|
const data = session.data
|
||||||
setStore(
|
if (!data) return
|
||||||
"session",
|
setStore(
|
||||||
produce((draft) => {
|
"session",
|
||||||
const match = Binary.search(draft, sessionID, (s) => s.id)
|
produce((draft) => {
|
||||||
if (match.found) {
|
const match = Binary.search(draft, sessionID, (s) => s.id)
|
||||||
draft[match.index] = data
|
if (match.found) {
|
||||||
return
|
draft[match.index] = data
|
||||||
}
|
return
|
||||||
draft.splice(match.index, 0, data)
|
}
|
||||||
}),
|
draft.splice(match.index, 0, data)
|
||||||
)
|
}),
|
||||||
})
|
)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (isNotFound(error) && !tracked(directory, sessionID)) return
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
|
||||||
const messagesReq =
|
const messagesReq =
|
||||||
cached && !opts?.force
|
cached && !opts?.force
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ import { usePlatform } from "@/context/platform"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||||
import { messageAgentColor } from "@/utils/agent"
|
import { messageAgentColor } from "@/utils/agent"
|
||||||
import { sessionTitle } from "@/utils/session-title"
|
import { sessionTitle } from "@/utils/session-title"
|
||||||
import { makeTimer } from "@solid-primitives/timer"
|
import { makeTimer } from "@solid-primitives/timer"
|
||||||
|
|
@ -861,7 +862,9 @@ export function MessageTimeline(props: {
|
||||||
if (index !== -1) draft.session.splice(index, 1)
|
if (index !== -1) draft.session.splice(index, 1)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
sync.session.evict(sessionID)
|
||||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||||
|
notifySessionTabsRemoved({ directory: sdk.directory, sessionIDs: [sessionID] })
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
|
|
@ -892,42 +895,46 @@ export function MessageTimeline(props: {
|
||||||
|
|
||||||
if (!result) return false
|
if (!result) return false
|
||||||
|
|
||||||
|
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 children = byParent.get(parentID)
|
||||||
|
if (!children) continue
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
if (removed.has(child)) continue
|
||||||
|
removed.add(child)
|
||||||
|
stack.push(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||||
|
|
||||||
sync.set(
|
sync.set(
|
||||||
produce((draft) => {
|
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 stack = [sessionID]
|
|
||||||
while (stack.length) {
|
|
||||||
const parentID = stack.pop()
|
|
||||||
if (!parentID) 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
draft.session = draft.session.filter((s) => !removed.has(s.id))
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue