Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/app/src/components/titlebar-session-events.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 29 additions & 0 deletions packages/app/src/components/titlebar-session-events.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
41 changes: 40 additions & 1 deletion packages/app/src/components/titlebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
51 changes: 33 additions & 18 deletions packages/app/src/context/directory-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
Expand Down
63 changes: 35 additions & 28 deletions packages/app/src/pages/session/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
}

Expand Down
Loading