From 042f96f6ba82bd2032616eac006f986b80e8900c Mon Sep 17 00:00:00 2001 From: Kail Tian Date: Wed, 2 Sep 2026 13:05:36 +0800 Subject: [PATCH] fix(webui): delete unpersisted pane sessions (#5624) Co-authored-by: chengyongru --- .../tests/test_websocket_http_routes.py | 42 +++++++++++++++ nanobot/webui/workspaces.py | 4 ++ nanobot/webui/ws_http.py | 5 +- webui/src/hooks/useSessions.ts | 7 ++- webui/src/tests/useSessions.test.tsx | 53 +++++++++++++++++++ 5 files changed, 108 insertions(+), 3 deletions(-) diff --git a/nanobot/channels/websocket/tests/test_websocket_http_routes.py b/nanobot/channels/websocket/tests/test_websocket_http_routes.py index 7db009867..5a9842e5b 100644 --- a/nanobot/channels/websocket/tests/test_websocket_http_routes.py +++ b/nanobot/channels/websocket/tests/test_websocket_http_routes.py @@ -2333,6 +2333,48 @@ async def test_session_delete_removes_transcript_without_canonical_file( await server_task +@pytest.mark.asyncio +async def test_session_delete_removes_unpersisted_new_chat( + bus: MagicMock, tmp_path: Path +) -> None: + sm = SessionManager(tmp_path / "sessions") + project = tmp_path / "project" + project.mkdir() + channel = _ch(bus, session_manager=sm, workspace_path=tmp_path, port=_free_port()) + connection = AsyncMock() + connection.remote_address = ("127.0.0.1", 50123) + + await channel._dispatch_envelope( + connection, + "webui-client", + { + "type": "new_chat", + "workspace_scope": { + "project_path": str(project), + "access_mode": "full", + }, + }, + ) + + attached = next( + payload + for payload in ( + json.loads(call.args[0]) for call in connection.send.await_args_list + ) + if payload.get("event") == "attached" + ) + key = f"websocket:{attached['chat_id']}" + + assert sm.list_sessions() == [] + assert channel.gateway.workspaces.scope_for_session_key(key).project_path == project.resolve() + + response = await _webui_mutate(channel, "session.delete", {"key": key}) + + assert response.status_code == 200 + assert response.json()["deleted"] is True + assert channel.gateway.workspaces.scope_for_session_key(key).project_path == tmp_path.resolve() + + @pytest.mark.asyncio async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( bus: MagicMock, tmp_path: Path diff --git a/nanobot/webui/workspaces.py b/nanobot/webui/workspaces.py index 6c1d393c8..15b9ea88d 100644 --- a/nanobot/webui/workspaces.py +++ b/nanobot/webui/workspaces.py @@ -357,3 +357,7 @@ class WebUIWorkspaceController: self._draft_scopes.move_to_end(session_key) while len(self._draft_scopes) > _MAX_DRAFT_SCOPES: self._draft_scopes.popitem(last=False) + + def discard_draft_scope(self, session_key: str) -> bool: + """Discard the staged scope for a chat that has not persisted yet.""" + return self._draft_scopes.pop(session_key, None) is not None diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 16d2f80d4..b5b870b82 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -952,9 +952,12 @@ class GatewayHTTPHandler: self.local_trigger_store.delete(job.id) elif self.cron_service is not None: self.cron_service.remove_job(job.id) + draft_deleted = self.workspaces.discard_draft_scope(decoded_key) session_deleted = self.session_manager.delete_session(decoded_key) transcript_deleted = delete_webui_thread(decoded_key) - return _http_json_response({"deleted": bool(session_deleted or transcript_deleted)}) + return _http_json_response( + {"deleted": bool(draft_deleted or session_deleted or transcript_deleted)} + ) # -- Automation routes -------------------------------------------------- diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts index 61748ce1a..8513554b6 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -257,11 +257,14 @@ export function useSessions(): { const deleteChat = useCallback( async (key: string, options?: { deleteAutomations?: boolean }) => { + const optimistic = optimisticKeysRef.current.has(key); const result = await apiDeleteSession(client, key, options); - if (!result.deleted) return result; + if (result.blocked_by_automations || (!result.deleted && !optimistic)) return result; optimisticKeysRef.current.delete(key); setSessions((prev) => prev.filter((s) => s.key !== key)); - return result; + // The gateway may have restarted and forgotten an unpersisted chat's + // draft scope, but removing that optimistic session is still a deletion. + return result.deleted ? result : { ...result, deleted: true }; }, [client], ); diff --git a/webui/src/tests/useSessions.test.tsx b/webui/src/tests/useSessions.test.tsx index dd0c95f6b..d01c02090 100644 --- a/webui/src/tests/useSessions.test.tsx +++ b/webui/src/tests/useSessions.test.tsx @@ -124,6 +124,59 @@ describe("useSessions", () => { expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]); }); + it("removes an optimistic chat when the gateway no longer has its draft", async () => { + vi.mocked(api.listSessions).mockResolvedValue([]); + vi.mocked(api.deleteSession).mockResolvedValue({ deleted: false }); + const client = fakeClient(); + client.newChat.mockResolvedValue("chat-empty"); + + const { result } = renderHook(() => useSessions(), { + wrapper: wrap(client), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { + await result.current.createChat(); + }); + expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-empty"]); + + let deleteResult: Awaited> | undefined; + await act(async () => { + deleteResult = await result.current.deleteChat("websocket:chat-empty"); + }); + + expect(deleteResult?.deleted).toBe(true); + expect(result.current.sessions).toEqual([]); + }); + + it("keeps an optimistic chat when delete is blocked by bound automations", async () => { + vi.mocked(api.listSessions).mockResolvedValue([]); + vi.mocked(api.deleteSession).mockResolvedValue({ + deleted: false, + blocked_by_automations: true, + automations: [], + }); + const client = fakeClient(); + client.newChat.mockResolvedValue("chat-empty"); + + const { result } = renderHook(() => useSessions(), { + wrapper: wrap(client), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { + await result.current.createChat(); + }); + + let deleteResult: Awaited> | undefined; + await act(async () => { + deleteResult = await result.current.deleteChat("websocket:chat-empty"); + }); + + expect(deleteResult?.blocked_by_automations).toBe(true); + expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-empty"]); + }); + it("keeps a session when delete is blocked by bound automations", async () => { vi.mocked(api.listSessions).mockResolvedValue([ {