From 3df8305cb6a29f7fe7ba00008fde4d4fbd2b16f0 Mon Sep 17 00:00:00 2001 From: Sebastian <19554889+sebslight@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:46:04 -0500 Subject: [PATCH] fix(ui): gate sessions refresh on successful delete --- CHANGELOG.md | 1 + ui/src/ui/app-render.ts | 9 +-- ui/src/ui/controllers/sessions.test.ts | 104 +++++++++++++++++++++++++ ui/src/ui/controllers/sessions.ts | 21 +++-- 4 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 ui/src/ui/controllers/sessions.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 69eccb8c3b..7768025dcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - Gateway/Channels: wire `gateway.channelHealthCheckMinutes` into strict config validation, treat implicit account status as managed for health checks, and harden channel auto-restart flow (preserve restart-attempt caps across crash loops, propagate enabled/configured runtime flags, and stop pending restart backoff after manual stop). Thanks @steipete. - UI/Usage: replace lingering undefined `var(--text-muted)` usage with `var(--muted)` in usage date-range and chart styles to keep muted text visible across themes. (#17975) Thanks @jogelin. - UI/Usage: preserve selected-range totals when timeline data is downsampled by bucket-aggregating timeseries points (instead of dropping intermediate points), so filtered tokens/cost stay accurate. (#17959) Thanks @jogelin. +- UI/Sessions: refresh the sessions table only after successful deletes and preserve delete errors on cancel/failure paths, so deleted sessions disappear automatically without masking delete failures. (#18507) - Mattermost: harden reaction handling by requiring an explicit boolean `remove` flag and routing reaction websocket events to the reaction handler, preventing string `"true"` values from being treated as removes and avoiding double-processing of reaction events as posts. (#18608) Thanks @echo931. - Scripts/UI/Windows: fix `pnpm ui:*` spawn `EINVAL` failures by restoring shell-backed launch for `.cmd`/`.bat` runners, narrowing shell usage to launcher types that require it, and rejecting unsafe forwarded shell metacharacters in UI script args. (#18594) - Hooks/Session-memory: recover `/new` conversation summaries when session pointers are reset-path or missing `sessionFile`, and consistently prefer the newest `.jsonl.reset.*` transcript candidate for fallback extraction. (#18088) diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 3924a497e9..b782419d2a 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1,10 +1,10 @@ import { html, nothing } from "lit"; +import type { AppViewState } from "./app-view-state.ts"; import { parseAgentSessionKey } from "../../../src/routing/session-key.js"; import { t } from "../i18n/index.ts"; import { refreshChatAvatar } from "./app-chat.ts"; import { renderUsageTab } from "./app-render-usage-tab.ts"; import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers.ts"; -import type { AppViewState } from "./app-view-state.ts"; import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts"; import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts"; import { loadAgentSkills } from "./controllers/agent-skills.ts"; @@ -44,7 +44,7 @@ import { import { loadLogs } from "./controllers/logs.ts"; import { loadNodes } from "./controllers/nodes.ts"; import { loadPresence } from "./controllers/presence.ts"; -import { deleteSession, loadSessions, patchSession } from "./controllers/sessions.ts"; +import { deleteSessionAndRefresh, loadSessions, patchSession } from "./controllers/sessions.ts"; import { installSkill, loadSkills, @@ -301,10 +301,7 @@ export function renderApp(state: AppViewState) { }, onRefresh: () => loadSessions(state), onPatch: (key, patch) => patchSession(state, key, patch), - onDelete: async (key) => { - await deleteSession(state, key); - await loadSessions(state); - }, + onDelete: (key) => deleteSessionAndRefresh(state, key), }) : nothing } diff --git a/ui/src/ui/controllers/sessions.test.ts b/ui/src/ui/controllers/sessions.test.ts new file mode 100644 index 0000000000..a110b564e9 --- /dev/null +++ b/ui/src/ui/controllers/sessions.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { deleteSession, deleteSessionAndRefresh, type SessionsState } from "./sessions.ts"; + +type RequestFn = (method: string, params?: unknown) => Promise; + +function createState(request: RequestFn, overrides: Partial = {}): SessionsState { + return { + client: { request } as unknown as SessionsState["client"], + connected: true, + sessionsLoading: false, + sessionsResult: null, + sessionsError: null, + sessionsFilterActive: "0", + sessionsFilterLimit: "0", + sessionsIncludeGlobal: true, + sessionsIncludeUnknown: true, + ...overrides, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("deleteSessionAndRefresh", () => { + it("refreshes sessions after a successful delete", async () => { + const request = vi.fn(async (method: string) => { + if (method === "sessions.delete") { + return { ok: true }; + } + if (method === "sessions.list") { + return undefined; + } + throw new Error(`unexpected method: ${method}`); + }); + const state = createState(request); + vi.spyOn(window, "confirm").mockReturnValue(true); + + const deleted = await deleteSessionAndRefresh(state, "agent:main:test"); + + expect(deleted).toBe(true); + expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenNthCalledWith(1, "sessions.delete", { + key: "agent:main:test", + deleteTranscript: true, + }); + expect(request).toHaveBeenNthCalledWith(2, "sessions.list", { + includeGlobal: true, + includeUnknown: true, + }); + expect(state.sessionsError).toBeNull(); + expect(state.sessionsLoading).toBe(false); + }); + + it("does not refresh sessions when user cancels delete", async () => { + const request = vi.fn(async () => undefined); + const state = createState(request, { sessionsError: "existing error" }); + vi.spyOn(window, "confirm").mockReturnValue(false); + + const deleted = await deleteSessionAndRefresh(state, "agent:main:test"); + + expect(deleted).toBe(false); + expect(request).not.toHaveBeenCalled(); + expect(state.sessionsError).toBe("existing error"); + expect(state.sessionsLoading).toBe(false); + }); + + it("does not refresh sessions when delete fails and preserves the delete error", async () => { + const request = vi.fn(async (method: string) => { + if (method === "sessions.delete") { + throw new Error("delete boom"); + } + if (method === "sessions.list") { + return undefined; + } + throw new Error(`unexpected method: ${method}`); + }); + const state = createState(request); + vi.spyOn(window, "confirm").mockReturnValue(true); + + const deleted = await deleteSessionAndRefresh(state, "agent:main:test"); + + expect(deleted).toBe(false); + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith("sessions.delete", { + key: "agent:main:test", + deleteTranscript: true, + }); + expect(state.sessionsError).toContain("delete boom"); + expect(state.sessionsLoading).toBe(false); + }); +}); + +describe("deleteSession", () => { + it("returns false when already loading", async () => { + const request = vi.fn(async () => undefined); + const state = createState(request, { sessionsLoading: true }); + + const deleted = await deleteSession(state, "agent:main:test"); + + expect(deleted).toBe(false); + expect(request).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts index 9bf1605f40..da72abc875 100644 --- a/ui/src/ui/controllers/sessions.ts +++ b/ui/src/ui/controllers/sessions.ts @@ -1,6 +1,6 @@ -import { toNumber } from "../format.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; import type { SessionsListResult } from "../types.ts"; +import { toNumber } from "../format.ts"; export type SessionsState = { client: GatewayBrowserClient | null; @@ -91,26 +91,37 @@ export async function patchSession( } } -export async function deleteSession(state: SessionsState, key: string) { +export async function deleteSession(state: SessionsState, key: string): Promise { if (!state.client || !state.connected) { - return; + return false; } if (state.sessionsLoading) { - return; + return false; } const confirmed = window.confirm( `Delete session "${key}"?\n\nDeletes the session entry and archives its transcript.`, ); if (!confirmed) { - return; + return false; } state.sessionsLoading = true; state.sessionsError = null; try { await state.client.request("sessions.delete", { key, deleteTranscript: true }); + return true; } catch (err) { state.sessionsError = String(err); + return false; } finally { state.sessionsLoading = false; } } + +export async function deleteSessionAndRefresh(state: SessionsState, key: string): Promise { + const deleted = await deleteSession(state, key); + if (!deleted) { + return false; + } + await loadSessions(state); + return true; +}