diff --git a/packages/app/src/home/sessions/controller.tsx b/packages/app/src/home/sessions/controller.tsx index 052de9fe8151..a0a7728e8150 100644 --- a/packages/app/src/home/sessions/controller.tsx +++ b/packages/app/src/home/sessions/controller.tsx @@ -5,7 +5,6 @@ import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode- import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query" import { DateTime } from "luxon" import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js" -import { createStore } from "solid-js/store" import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events" import { useCommand } from "@/shell/commands/command" import { loadHomeSessionIndex, mergeHomeSessionIndex, retainHomeSessions } from "@/home/sessions/index" @@ -43,7 +42,6 @@ export function createHomeSessionsController(home: HomeController) { const dialog = useDialog() const language = useLanguage() const queryClient = useQueryClient() - const [removed, setRemoved] = createStore({ keys: [] as string[] }) const projectDirectories = createMemo(() => { const selected = home.selection.value().directory if (!selected) return @@ -70,10 +68,9 @@ export function createHomeSessionsController(home: HomeController) { const ctx = home.server.focusedContext() const conn = home.server.focused() if (!ctx || !conn) return [] - const server = ServerConnection.key(conn) return retainHomeSessions( - mergeHomeSessionIndex(sessionLoad.isPending ? [] : (sessionLoad.data?.() ?? []), ctx.data.session.list()).filter( - (session) => !removed.keys.includes(`${server}\0${session.id}`), + ctx.data.session.apply( + mergeHomeSessionIndex(sessionLoad.isPending ? [] : (sessionLoad.data?.() ?? []), ctx.data.session.list()), ), HOME_SESSION_LIMIT, Date.now(), @@ -192,15 +189,9 @@ export function createHomeSessionsController(home: HomeController) { const ctx = conn ? home.server.context(conn) : undefined if (!conn || !ctx) return false const ids = [...removedSessionIDs(ctx.data.session.list(), session.id)] - await queryClient.cancelQueries({ queryKey: ["home-sessions", conn], exact: true }) - return ctx.sdk.api.session - .remove({ sessionID: session.id }) + return ctx.data.session + .remove(session.id) .then(() => { - const removedIDs = new Set(ids) - setRemoved("keys", (current) => [...new Set([...current, ...ids.map((id) => `${server}\0${id}`)])]) - queryClient.setQueryData(["home-sessions", conn], (current) => - current?.filter((item) => !removedIDs.has(item.id)), - ) notifySessionTabsRemoved({ server: ServerConnection.key(conn), directory: session.location.directory, @@ -216,9 +207,6 @@ export function createHomeSessionsController(home: HomeController) { return false }) .finally(() => { - // Always refetch: the pre-mutation cancel may have aborted an - // in-flight index fetch, and a failed delete must not leave the - // index unloaded either. void queryClient.invalidateQueries({ queryKey: ["home-sessions", conn], exact: true }) }) } diff --git a/packages/app/src/runtime/server/data.test.ts b/packages/app/src/runtime/server/data.test.ts new file mode 100644 index 000000000000..c6ee02240734 --- /dev/null +++ b/packages/app/src/runtime/server/data.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from "bun:test" +import type { SessionInfo } from "@opencode-ai/client/promise" +import { createSessionMutations } from "./data" + +const session = { id: "ses_test" } as SessionInfo + +test("keeps a successful removal applied until its event arrives", async () => { + const release = Promise.withResolvers() + const mutation = createSessionMutations(async () => release.promise) + + const request = mutation.remove(session.id) + expect(mutation.apply([session])).toEqual([]) + release.resolve() + await request + expect(mutation.apply([session])).toEqual([]) + + mutation.deleted(session.id) + expect(mutation.apply([session])).toEqual([session]) +}) + +test("rolls back a failed removal", async () => { + const release = Promise.withResolvers() + const mutation = createSessionMutations(async () => { + await release.promise + throw new Error("offline") + }) + + const request = mutation.remove(session.id) + expect(mutation.apply([session])).toEqual([]) + release.resolve() + await expect(request).rejects.toThrow("offline") + expect(mutation.apply([session])).toEqual([session]) +}) diff --git a/packages/app/src/runtime/server/data.ts b/packages/app/src/runtime/server/data.ts new file mode 100644 index 000000000000..5be4b7ba1912 --- /dev/null +++ b/packages/app/src/runtime/server/data.ts @@ -0,0 +1,51 @@ +import type { Data } from "@opencode-ai/client/solid" +import type { SessionInfo } from "@opencode-ai/client/promise" +import { onCleanup } from "solid-js" +import { createStore } from "solid-js/store" + +type SessionMutation = { readonly id: string; readonly type: "remove"; readonly sessionID: string } + +export function createDesktopData(input: { data: Data; remove: (sessionID: string) => Promise }) { + const mutation = createSessionMutations(input.remove) + onCleanup(input.data.on("session.deleted", (event) => mutation.deleted(event.data.sessionID))) + + return { + ...input.data, + session: { + ...input.data.session, + list: () => mutation.apply(input.data.session.list()), + apply: mutation.apply, + remove: mutation.remove, + }, + } +} + +export function createSessionMutations(remove: (sessionID: string) => Promise) { + const [store, setStore] = createStore({ session: [] as SessionMutation[] }) + + const clear = (id: string) => { + setStore("session", (current) => current.filter((mutation) => mutation.id !== id)) + } + + return { + apply(sessions: readonly SessionInfo[]) { + const removed = new Set( + store.session.flatMap((mutation) => (mutation.type === "remove" ? [mutation.sessionID] : [])), + ) + return removed.size === 0 ? [...sessions] : sessions.filter((session) => !removed.has(session.id)) + }, + remove(sessionID: string) { + const mutation = { id: crypto.randomUUID(), type: "remove" as const, sessionID } + setStore("session", (current) => [...current, mutation]) + return Promise.resolve() + .then(() => remove(sessionID)) + .catch((error) => { + clear(mutation.id) + throw error + }) + }, + deleted(sessionID: string) { + setStore("session", (current) => current.filter((mutation) => mutation.sessionID !== sessionID)) + }, + } +} diff --git a/packages/app/src/runtime/server/runtime.tsx b/packages/app/src/runtime/server/runtime.tsx index 152065e9357c..639c845b0db7 100644 --- a/packages/app/src/runtime/server/runtime.tsx +++ b/packages/app/src/runtime/server/runtime.tsx @@ -11,6 +11,7 @@ import type { ServerScope } from "@/runtime/server/scope" import { createPermissionAutoApprover } from "@/session/requests/auto-approve" import { createServerNotificationState } from "@/shell/notifications/notification" import { Persist, persisted } from "@/runtime/persistence/storage" +import { createDesktopData } from "./data" export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({ name: "Global", @@ -134,7 +135,7 @@ function createServerController( ) { const connKey = ServerConnection.key(conn) const sdk = createServerSdkContext(conn, scope) - const data = createData({ + const source = createData({ api: () => sdk.api, event: { on: sdk.event.on, @@ -143,6 +144,10 @@ function createServerController( connection: sdk.connection, directory: "", }) + const data = createDesktopData({ + data: source, + remove: (sessionID) => sdk.api.session.remove({ sessionID }), + }) const sync = createServerSyncContext(sdk, data) createPermissionAutoApprover({ sdk, data }) const notification = createServerNotificationState({ sdk, data, key: connKey }) diff --git a/packages/app/src/session/timeline/controller.tsx b/packages/app/src/session/timeline/controller.tsx index e7a676b7e3ba..e84e800c7805 100644 --- a/packages/app/src/session/timeline/controller.tsx +++ b/packages/app/src/session/timeline/controller.tsx @@ -168,15 +168,15 @@ export function createTimelineController(input: { session: TimelineSessionSource const sessions = data.session.list().filter((item) => !item.parentID && !item.time?.archived) const index = sessions.findIndex((item) => item.id === id) const next = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) - const success = await serverSDK.api.session - .remove({ sessionID: id }) + const removed = removedSessionIDs(data.session.list(), id) + const success = await data.session + .remove(id) .then(() => true) .catch((error) => { showToast({ title: language.t("session.delete.failed.title"), description: errorMessage(error) }) return false }) if (!success) return false - const removed = removedSessionIDs(data.session.list(), id) void navigateAfterRemoval(id, session.parentID, next?.id) notifySessionTabsRemoved({ server: server.key, directory: sdk().directory, sessionIDs: [...removed] }) return true