diff --git a/packages/app/src/components/titlebar-session-events.test.ts b/packages/app/src/components/titlebar-session-events.test.ts new file mode 100644 index 000000000000..e1913e946a41 --- /dev/null +++ b/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() + }) +}) diff --git a/packages/app/src/components/titlebar-session-events.ts b/packages/app/src/components/titlebar-session-events.ts new file mode 100644 index 000000000000..aa6b5e1d1150 --- /dev/null +++ b/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, + } +} diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index e7bb9cc9ea01..8e69cd47ab52 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/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 @@ -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 diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index a54701f0db84..6f3953f148ea 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/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(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 { + 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 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 diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index c014cb5d96e8..e071597c8ab1 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/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([sessionID]) - - const byParent = new Map() - 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([sessionID]) + const byParent = new Map() + 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 }