Compare commits

...
Author SHA1 Message Date
Brendonovich a018475178 fix(app): reconcile deleted sessions and notify open tabs 2026-09-24 05:49:50 +00:00
4 changed files with 158 additions and 2 deletions
@@ -105,4 +105,6 @@ test("renames, exports, and deletes a home session from its context menu", async
await dialog.getByRole("button", { name: "Delete session" }).click()
await removed
await expect(renamedRow).toBeHidden()
await expect(page.getByText("Session deleted", { exact: true })).toBeVisible()
await expect(page.locator(`[data-slot="titlebar-tabs"] a[href$="/session/${fixture.targetID}"]`)).toHaveCount(0)
})
@@ -0,0 +1,113 @@
import { expect, test } from "@playwright/test"
import type { OpenCodeEvent } from "@opencode/client/promise"
import { fixture } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test("external deletion leaves an open tab on the missing-session screen, toasts, and removes the Home row", async ({
page,
}) => {
const sessions = fixture.sessions.map((session) => ({ ...session }))
const events: OpenCodeEvent[] = []
await mockOpenCodeServer(page, {
sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: () => ({ items: [] }),
events: () => events.splice(0),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
await page.goto("/")
const row = page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`)
await expect(row).toBeVisible()
await row.locator('[data-component="home-session-row"]').click()
await expect(page.getByRole("button", { name: "More options" })).toBeVisible()
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href$="/session/${fixture.targetID}"]`)
await expect(tab).toBeVisible()
sessions.splice(
sessions.findIndex((session) => session.id === fixture.targetID),
1,
)
events.push({
id: "evt_target_deleted",
created: 1700000003000,
type: "session.deleted",
durable: { aggregateID: fixture.targetID, seq: 1, version: 2 },
location: { directory: fixture.directory },
data: { sessionID: fixture.targetID },
})
await expect(page.getByText("This session cannot be found")).toBeVisible()
await expect(tab).toBeVisible()
await expect(page.getByText("Session deleted", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Home" }).click()
await expect(
page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.sourceID}"]`),
).toBeVisible()
await expect(row).toHaveCount(0)
})
test("deletion event for a session without an open tab does not toast", async ({ page }) => {
const sessions = fixture.sessions.map((session) => ({ ...session }))
const events: OpenCodeEvent[] = []
await mockOpenCodeServer(page, {
sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: () => ({ items: [] }),
events: () => events.splice(0),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
await page.goto("/")
const row = page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`)
await expect(row).toBeVisible()
const refill = Promise.withResolvers<void>()
await page.route(/\/api\/session(?:\?|$)/, async (route) => {
if (route.request().method() !== "GET") return route.fallback()
await refill.promise
return route.fallback()
})
sessions.splice(
sessions.findIndex((session) => session.id === fixture.targetID),
1,
)
const refetch = page.waitForRequest(
(request) => request.method() === "GET" && /\/api\/session(?:\?|$)/.test(request.url()),
)
events.push({
id: "evt_unopened_deleted",
created: 1700000003000,
type: "session.deleted",
durable: { aggregateID: fixture.targetID, seq: 1, version: 2 },
location: { directory: fixture.directory },
data: { sessionID: fixture.targetID },
})
try {
await refetch
await expect(row).toHaveCount(0)
await expect(page.getByText("Session deleted", { exact: true })).toHaveCount(0)
} finally {
refill.resolve()
}
})
+1
View File
@@ -1497,6 +1497,7 @@ export const dict = {
"settings.permissions.tool.doom_loop.description": "Detect repeated tool calls with identical input",
"session.delete.failed.title": "Failed to delete session",
"toast.session.deleted": "Session deleted",
"session.delete.title": "Delete session",
"session.delete.confirm": 'Delete session "{{name}}"?',
"session.delete.button": "Delete session",
+42 -2
View File
@@ -1,5 +1,7 @@
import { createSimpleContext } from "@opencode/ui/context"
import { Accessor, batch, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
import { Accessor, batch, createEffect, createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
import { useQueryClient } from "@tanstack/solid-query"
import type { SessionInfo } from "@opencode/client/promise"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
import { pathKey } from "@/workspaces/path-key"
import { useServerHealth } from "@/runtime/server/health"
@@ -18,6 +20,7 @@ import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "./errors"
import { useSettings } from "@/settings/model"
import { timelinePreset } from "@opencode/session-ui/timeline/detail"
import { useTabs } from "@/shell/tabs/tabs"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
@@ -128,8 +131,35 @@ function createServerController(
) {
const language = useLanguage()
const settings = useSettings()
const tabs = useTabs()
const queryClient = useQueryClient()
const connKey = ServerConnection.key(conn)
const sdk = createServerSdkContext(conn, scope)
const notifiedDeletionIDs = new Set<string>()
const forgetDeletedSession = (sessionID: string) => {
let removed = false
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) => {
if (!current?.some((session) => session.id === sessionID)) return current
removed = true
return current.filter((session) => session.id !== sessionID)
})
if (removed) void queryClient.invalidateQueries({ queryKey: ["home-sessions", conn], exact: true })
}
const notifyOpenSessionDeleted = (sessionID: string) => {
if (notifiedDeletionIDs.has(sessionID)) return
if (
!tabs.store.some(
(tab) =>
tab.type === "session" &&
tab.server === connKey &&
(tab.sessionId === sessionID || tab.routeSessionId === sessionID),
)
)
return
if (notifiedDeletionIDs.size >= 128) notifiedDeletionIDs.delete(notifiedDeletionIDs.values().next().value!)
notifiedDeletionIDs.add(sessionID)
showToast({ variant: "success", title: language.t("toast.session.deleted") })
}
const source = createData({
api: () => sdk.api,
initialMessageLimit: () => (timelinePreset(settings.general.timelineDetail())?.id === "compact" ? 40 : 20),
@@ -149,8 +179,18 @@ function createServerController(
})
const data = createDesktopData({
data: source,
remove: (sessionID) => sdk.api.session.remove({ sessionID }),
remove: async (sessionID) => {
await sdk.api.session.remove({ sessionID })
forgetDeletedSession(sessionID)
notifyOpenSessionDeleted(sessionID)
},
})
onCleanup(
sdk.event.on("session.deleted", (event) => {
forgetDeletedSession(event.data.sessionID)
notifyOpenSessionDeleted(event.data.sessionID)
}),
)
const sync = createServerSyncContext(sdk, data)
createPermissionAutoApprover({ sdk, data })
const notification = createServerNotificationState({ sdk, data, key: connKey, coordinator: notificationCoordinator })